6.4 KiB
fs
Whole-file filesystem access, guarded by a per-script folder permission
system. The sandbox loads no stock io; this module is the supported way for
a script to touch files, and every operation first passes a permission check
(see Permissions below).
The v1 surface is deliberately small — whole files in and out, directory listing, two predicates, and an explicit permission probe:
local text = fs.readAll("/home/me/notes.txt")
fs.writeAll("out/report.md", text .. "\n-- processed")
for _, name in ipairs(fs.list(".")) do print(name) end
if fs.isFile("config.json") then ... end
if fs.access("/var/data", "w") then ... end
Functions
fs.readAll(path) -> string
Reads the whole file into a string. Lua strings are byte strings, so binary
content round-trips exactly. Errors if path is not a regular file, the OS
refuses, or the permission system denies read on the file's directory.
fs.writeAll(path, content)
Writes the whole file: created if missing, truncated if it exists. The containing directory must already exist (there is no mkdir-p), and a dangling symlink as the target is refused. Errors if the file can't be created or the permission system denies write on the containing directory.
fs.list(path) -> string[]
The names (not full paths) of a directory's entries, sorted. . and .. are
not included. Errors if path is not a directory or read on it is
denied.
fs.isDir(path) -> boolean, fs.isFile(path) -> boolean
true iff the path exists, has the right type, and is readable under the
permission system. These never error: a nonexistent path is false without
any permission prompt; an unknown permission prompts like any other read (a
denial makes the result false).
fs.getWorkDir() -> string
The process's current working directory, as an absolute path. Relative paths
given to the other fs functions resolve against it, so it is the natural base
for building paths to probe with fs.access:
local cwd = fs.getWorkDir()
if fs.access(cwd, "w") then
fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt"
end
Not permission-gated — it reports process state and touches no file content.
fs.getScriptDir() -> string|nil
The directory the main script really lives in, as an absolute path with
symlinks resolved — an installed script invoked through a ~/.local/bin
symlink gets the directory of the real file. Unlike the working directory,
this doesn't depend on where the script was invoked from, so it is the right
base for assets shipped alongside the script:
local tmpl = fs.readAll(fs.getScriptDir() .. "/templates/report.html")
Returns nil in the REPL, where there is no script. Not permission-gated —
it names a place and reads no file content (accessing files under it prompts
like anywhere else).
fs.access(dir, mode) -> boolean
Runs the permission cycle for a directory explicitly — mode is "r" or
"w":
- a recorded grant/denial answers immediately;
- otherwise the interactive prompt asks;
- with no terminal to ask on, returns
falsewithout recording a denial (a persisted "no" is always an explicit user decision).
Use it to ask for permission up front at a natural moment instead of
mid-operation. Errors if dir is not an existing directory.
Permissions
Permissions attach to directories, not files, and are recorded per script:
- The permission unit for
readAll/writeAll/isFileis the file's directory; forlist/isDir/accessit is the directory itself. Paths are canonicalized first (symlinks resolved, relative paths against the CWD), so two spellings of the same place share one grant. - Grants are recursive: an allow or deny on a directory covers
everything below it, and the deepest recorded ancestor wins — deny
~/secretsand it stays denied even though~is allowed. - Read and write are tracked separately; each is approved, denied, or not decided yet.
When an operation needs a decision that isn't recorded, a asks on the
terminal (stderr, so it shows even with stdout piped) — one keypress, no
Enter:
a: permission request
script: /home/me/bin/deploy.lua
wants: read access to directory /home/me/project
[y] allow once [n] deny once [A] allow always [N] never allow:
y/napply for the rest of this run only.A/Nare recorded inaccess.jsonand answer silently from then on. (A lowercaseadeliberately does nothing — a slip of the finger must not persist anything.)- Esc / Ctrl-C count as
n. - No terminal (piped stdin, cron): the operation is denied for this run and nothing is recorded.
access.json
Recorded answers live in $XDG_CONFIG_HOME/a/access.json (usually
~/.config/a/access.json), keyed by the script's canonical path — moving or
renaming a script starts it with a clean slate. The file is plain JSON and
safe to hand-edit; each flag is true (approved), false (denied), or
null (not decided yet):
{
"version": 1,
"scripts": {
"/home/me/bin/deploy.lua": {
"/home/me/project": { "read": true, "write": true },
"/etc": { "read": true, "write": false }
}
}
}
Concurrent a processes update it safely (the write cycle holds a file
lock and replaces the file atomically).
The REPL prompts the same way, but its answers — including A/N —
last only for the session; an interactive session has no script identity to
record them under.
sqlite and http respect the same system
sqlite.connect(path)asks one combined read/write question for the database's directory (SQLite keeps WAL/journal files next to it). Memory-class databases —":memory:",""and theirfile:URI spellings — touch no file and are always allowed, even under--no-fs/--sandbox.- A session's cookie-jar files (
http.session(path),session:load,session:save) are read/write checked like any other file.
CLI flags
| Flag | Effect |
|---|---|
--allow-fs |
Skip the permission system: everything granted, nothing asked or recorded. |
--no-fs |
Deny all filesystem access (fs module, on-disk sqlite databases, cookie-jar files). |
--sandbox |
--no-fs, plus networking is cut (http errors); overrides --allow-fs. |
Denials from a flag read fs.readAll: filesystem access disabled by --no-fs,
interactive/recorded ones fs.readAll: access to /some/dir denied.