Filesystem Host Capabilities
Filesystem access is exposed through the capability-aware harness.fs
sub-handle. Free filesystem globals do not exist in the script-facing
language: the nominal handle is both the interface and the authority. This
makes filesystem use visible in helper signatures and lets the contract
registry, policy engine, graph, lint repairs, and runtime receipts agree on the
same call.
| Method | Static effect ceiling |
|---|---|
harness.fs.read_text(path) | workspace.read_text |
harness.fs.read_text_result(path) | workspace.read_text |
harness.fs.read_bytes(path) | workspace.read_text |
harness.fs.write_text(path, content) | workspace.write_text |
harness.fs.write_bytes(path, content) | workspace.write_text |
harness.fs.replace_text(path, content, options?) | workspace.write_text |
harness.fs.replace_text_result(path, content, options?) | workspace.write_text |
harness.fs.replace_bytes(path, content, options?) | workspace.write_text |
harness.fs.replace_bytes_result(path, content, options?) | workspace.write_text |
harness.fs.exists(path) | workspace.exists |
harness.fs.status(path, access?) | workspace.exists |
harness.fs.delete(path) | workspace.delete |
harness.fs.append(path, content) | workspace.write_text |
harness.fs.append_locked(path, content, options?) | workspace.write_text |
harness.fs.list_dir(path?) | workspace.list |
harness.fs.mkdir(path) | workspace.write_text |
harness.fs.copy(src, dst) | workspace.write_text |
harness.fs.temp_dir() | none |
harness.fs.workspace_temp_dir() | workspace.write_text |
harness.fs.mkdtemp_in_workspace(prefix?) | workspace.write_text |
harness.fs.mkdtemp(prefix?) | workspace.write_text |
harness.fs.stat(path) | workspace.exists |
harness.fs.rename(src, dst) | workspace.write_text |
harness.fs.read_lines(path) | workspace.read_text |
harness.fs.read_lines_page_result(path, options?) | workspace.read_text |
harness.fs.walk(path, options?) | workspace.list |
harness.fs.glob(pattern, base_or_options?, options?) | workspace.list |
harness.fs.find_text(root, pattern, options?) | workspace.list + workspace.read_text |
harness.fs.find_evidence(roots, patterns, options?) | workspace.list + workspace.read_text for every root |
harness.fs.read_text_result(path) returns a closed structured I/O failure
with stable kind values such as not_found, permission_denied,
invalid_data, and sandbox_denied. Branch on kind; keep message for
diagnostics rather than parsing its prose.
harness.fs.read_lines_page_result(path, options?) returns complete UTF-8
lines plus an exact byte-and-line cursor. max_lines and max_bytes bound each
page; a larger individual line returns file_too_large rather than partial
text. Application code normally imports the typed wrapper from std/fs or the
JSONL readers from std/jsonl.
The replacement methods update a complete file only when the optional
expected_sha256 lease still matches. They return created, replaced,
no_op, or stale; stale is a successful receipt and never writes. Digests
use lowercase sha256:<64 hex digits>. create, overwrite, and
create_parents default to true. Symlink destinations are rejected.
The default namespace durability means readers cannot observe a partial
payload. {durability: "flush"} also requests a payload and namespace flush;
file_synced and namespace_synced report what the operating system
completed. These fields do not claim stronger guarantees than the filesystem
or storage hardware provides. Import the typed replace_text[_result] and
replace_bytes[_result] wrappers from std/fs for application code.
harness.fs.workspace_temp_dir() returns a workspace-local scratch directory,
creating it lazily. Sandboxed runs place the directory inside the active
workspace root; unsandboxed runs use .harn-tmp relative to the script source
root.
harness.fs.mkdtemp_in_workspace(prefix?) creates a uniquely named directory
under harness.fs.workspace_temp_dir(). Prefer this for intermediate files
that later filesystem or process calls must read under the same sandbox policy.
harness.fs.mkdtemp(prefix?) creates a uniquely named directory under the host
temporary directory and returns its absolute path. Use it only for host-temp
work that does not need to be visible through workspace sandbox rules. The
directory is not automatically removed; callers own cleanup with
harness.fs.delete(path).
harness.fs.glob(pattern, base_or_options?, options?) returns sorted matches.
Patterns are matched against forward-slash paths relative to the base
directory, and the background option returns a long-running operation
handle.
Every filesystem walk — glob, walk_dir, find_text, find_evidence, and
project scanning — shares one ignore stack, selected per call with
ignore_policy:
| Level | Behavior |
|---|---|
"none" | Raw walk. Nothing is skipped. |
"builtin" | Harn's built-in directory defaults only (.git, node_modules, target, dist, build, coverage, .next, .venv, venv, __pycache__, .hg, .svn, and Harn's own .harn, .harn-runs, .harn-tmp). |
"project" | The built-in defaults plus .gitignore, .ignore, and .agentignore, in that order of increasing precedence. |
Defaults differ by surface, deliberately:
| Surface | Default |
|---|---|
glob, walk_dir, find_text, find_evidence, project_scan, hostlib tools/search, hostlib scanner | "project" |
harness.fs_watch.subscribe(request) | "builtin" |
Watching applies universal hygiene but not project ignore rules: a recursive
watch over node_modules or target exhausts the OS watch budget and delivers
churn nobody consumes, while a .gitignore says to keep a file out of version
control, which is not the claim that it should change silently — a developer
editing a
gitignored file still needs its events, and a dropped event is
indistinguishable from nothing having happened.
The built-in defaults are the lowest layer, so a project ignore file can take
them back: a .gitignore containing !dist/ re-includes dist. vendor is
deliberately not a built-in skip, because committed go mod vendor trees are
tracked source.
Results are deterministic by construction: machine-local ignore sources
(core.excludesFile, .git/info/exclude) are never read, and only ignore
files at or below the walk base participate. Two walks of the same repository
at the same commit return the same list on any machine.
Project ignore files only apply inside a project — a directory with a .git
(or .jj) entry above it, or a sandbox workspace root. Outside one, and inside
a Harn scratch directory (harness.fs.workspace_temp_dir(),
harness.fs.mkdtemp_in_workspace()), a "project" walk degrades to
"builtin". That is what keeps the * .gitignore Harn writes into its own
scratch dirs from blanking a walk, while still skipping node_modules in an
unmanaged tree. A .gitignore sitting next to a directory that is not a
checkout no longer filters anything.
Naming an ignored directory
The walker prunes an ignored directory before any pattern is tested, so where you name that directory decides whether you can reach it:
// Returns nothing: `target` is pruned as a descendant, and the pattern never
// gets a chance to run against anything inside it.
harness.fs.glob("target/**/*.rs")
// Returns matches: the walk root is never filtered by the rule that would
// have ignored it.
harness.fs.glob("**/*.rs", "target")
// Also returns matches: opt out of the layer that hides it.
harness.fs.glob("target/**/*.rs", {base: ".", ignore_policy: "builtin"})
This mirrors ripgrep, where rg --files build lists files under build/ even
when a parent .gitignore excludes build/. If a glob that used to work now
returns an empty list, check whether its pattern names an ignored directory
that should be the base instead.
Hidden-file filtering is a separate axis. glob and walk_dir always list
dotfiles, so harness.fs.glob(".github/workflows/*.yml") works; find_text and
find_evidence keep their include_hidden: false default.
harness.fs.find_text(root, pattern, options?) walks with gitignore-aware
defaults and searches matching files in the VM. It returns a list of
{path, line, col, column, text} hits by default. Set mode: "exists" for a
boolean short-circuit or mode: "count" for an integer count. The search is
fixed-string by default for lint/source-guard workloads; pass
{fixed_strings: false} to treat pattern as a regular expression.
preset: "source" adds common source-tree excludes (node_modules, target,
dist, .git, .harn-runs) and a 1 MiB file-size ceiling. To search
everything, pass {ignore_policy: "none", include_hidden: true}. Use
include, exclude, or their *_globs forms with glob strings or lists for
explicit overrides. Count mode is capped by max_matches (default 1000).
Summary modes can set parallel: true and optional threads for a parallel
walker.
harness.fs.find_evidence(roots, patterns, options?) accepts labeled
{id, path} roots and labeled {id, text} literals. It walks each root once,
matches all literals with one matcher, and returns deterministic path-relative
hits plus settled per-root failures and match-budget receipts. Root paths are
not copied into the receipt. Its case_insensitive option folds ASCII letters.
Set background for the standard cancellable operation handle. The
search_evidence and search_evidence_background helpers from std/fs
require a HarnessFs argument and provide typed Harn options and results
without recovering broader authority.
For direct CLI runs, harn run --write-root <path> adds an external writable
root to the same sandbox policy used for the primary workspace. Prefer it over
--no-sandbox when a script needs to update a declared output folder outside
the project tree. --read-only-root <path> remains the additive read-only
variant for shared assets or sibling repositories.