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

Website: https://harnlang.com/host-capabilities/fs.html

This page documents Harn, which is pre-1.0. Language, standard library, and CLI APIs may change. If the intended version is unclear, clarify before using this page.

---

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.read_lines_append_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`.

`harness.fs.read_lines_append_page_result(path, options?)` is the growing-file
variant. It returns only newline-terminated lines and leaves an unterminated
tail behind the committed byte offset. Its opaque file identity and observed
size distinguish append from replacement or truncation. Pass a stable
`generation` (for example a launch id) initially, or override the generation on
a later call to reject a cursor reused by another logical producer. Resets
restart at byte zero and report `generation_changed`,
`file_replaced`, or `file_truncated` instead of requiring stat/prose heuristics.

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` and `.agentignore`, in that order of increasing precedence. `.ignore` (the ripgrep/fd convention) is not honored: git cannot evaluate it, so a host implementing the same walk cannot reproduce it. |

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:

```harn
// 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.

---

## Read next

- [Runtime context](https://harnlang.com/runtime-context.md)
- [Language specification](https://harnlang.com/language-spec.md)
