Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

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.

MethodStatic 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:

LevelBehavior
"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:

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