---
title: "Local files"
description: "Read and write the folders your server app declares, and nothing else. Every use is checked, recorded with its file and line, and shown in mainsdk info."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "app-permissions"
canonical: "https://mainsdk.dev/docs/interfaces/disk"
updated: "2026-10-28"
---

# Local files

> Read and write the folders your server app declares, and nothing else. Every use is checked, recorded with its file and line, and shown in mainsdk info.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/disk · Updated: 2026-10-28

`disk` gives a server-side app its own folders for a cache, a report or a content folder. The app declares them in `main.yaml`, and any other path is refused.

It works like the phone permission for files. The app never reaches `node:fs` directly; `mainsdk check` fails on that (`MAIN-PERM-0001`). `disk` is server side only, and it is free on every plan.

Use `disk` for files the app owns. For files users send you, use [`files`](https://mainsdk.dev/docs/interfaces/files): uploads are scanned, private to their owner and never read from a path the app chose.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/app-permissions.md
- Category: Platform, OS and network

Calls:

- `disk.read(path)`
- `disk.readText(path)`
- `disk.write(path, data)`
- `disk.list(path)`
- `disk.exists(path)`
- `disk.delete(path)`
- `createDisk(perm)`
- `validateDisk(disk)`
- `disk.temp(fn, { prefix? })`
- `disk.remove(path)`
- `disk.space(path)`
- `disk.usage(path)`

In main.yaml:

```yaml
permissions:
  mode: enforce
  disk:
    read: [content]
    write: [.cache]
```

Guarantees, whatever the driver:

- Paths outside the declared folders are refused, including through symlinks
- A folder declared for write can also be read; nothing else is reachable
- Every use is recorded with the calling file and line
- Server side only; the browser has no disk


## Drivers

Pick one in main.yaml with `disk.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-PERM-0001` | check | Raw `node:fs`, `node:os` or `node:child_process` used outside a driver |
| `MAIN-PERM-0003` | check | A `disk` path, a `system` fact or a program found in the code isn't declared under `permissions` |
| `MAIN-PERM-0008` | check | A disk value is invalid: a `max` or `reserve` that isn't a size or a percentage, a `reserve` over 50%, or `temp` set to a folder |
| `MAIN-PERM-0002` | runtime | In `enforce` mode, a `disk` path, a `system` fact or a program is used but not declared under `permissions` (403) |
| `MAIN-PERM-0006` | runtime |  |
| `MAIN-PERM-0007` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Declared read and write folders under permissions.disk | 1 | 0.3 | `app-permissions` |
| Enforce and observe modes | 1 | 0.3 | `app-permissions` |
| Undeclared folder use found by mainsdk check | 1 | 0.3 | `app-permissions` |
| Decisions shown by mainsdk info | 1 | 0.3 | `app-permissions` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
permissions:
  mode: enforce
  disk:
    read: [content]
    write: [.cache]
```

```ts title="lib/cache.ts"
import { disk } from "mainsdk";

export async function saveRates(rates: unknown) {
  // allowed: .cache is under permissions.disk.write
  await disk.write(".cache/rates.json", JSON.stringify(rates));
}

export async function readArticle(slug: string) {
  // allowed: content is under permissions.disk.read
  return disk.readText(`content/${slug}.md`);
}
```

A path outside the declared folders is refused with `MAIN-PERM-0002` (403) in `enforce` mode.

## Configuration

Every key below lives under `permissions:`. Paths are relative to the app folder.

```yaml title="main.yaml"
permissions:
  mode: enforce             # observe | enforce
  disk:
    read: [content]         # folders the app may read
    write:                  # folders the app may write; these are readable too
      - { path: .cache, max: 500MB }
      - out
    temp: { max: 1GB }      # allow disk.temp; max covers all live temporary folders together
    reserve: 5%             # refuse writes when free space is below this
```

| Key | Type | What it does |
| --- | --- | --- |
| `mode` | string | `enforce` refuses an undeclared use with `MAIN-PERM-0002`. `observe` lets it run and records it as a refusal that was not enforced, so you can read what the app uses before you enforce |
| `disk.read` | list | Folders the app may read. A folder in `write` is readable without listing it here too |
| `disk.write` | list | Folders the app may create, change and remove files in. An entry is a path, or `{ path, max }` to cap the folder's size |
| `disk.temp` | `true` or `{ max }` | Turns on `disk.temp`. `max` caps all live temporary folders together |
| `disk.reserve` | size or percent | Writes fail while the filesystem has less free space than this. Default `1%`; `0` turns it off; a percentage above 50% is invalid |

Declare the narrowest folder you can. `disk.write: [.]` lets the app write everything in its folder, which is almost never what you want.

## What you get

Build a `disk` yourself with `createDisk(perm)`, which takes a function that returns the permission layer. `validateDisk(permissions.disk)` checks the `permissions.disk` section and returns one issue per problem, which `mainsdk check` reports as `MAIN-PERM-0008`.

| Call | What it does |
| --- | --- |
| `disk.read(path)` | Read a file as bytes (`Uint8Array`) |
| `disk.readText(path)` | Read a file as UTF-8 text |
| `disk.write(path, data)` | Write a string or bytes; missing parent folders are created. The write is atomic: a reader sees the old file or the new one, never half of it, and the file keeps its mode |
| `disk.list(path)` | List the names in a folder, sorted |
| `disk.exists(path)` | `true` when the path exists |
| `disk.delete(path)` | Remove a file or folder, recursively; a missing path is not an error |
| `disk.remove(path)` | Deprecated alias of `disk.delete` |
| `disk.temp(fn, { prefix })` | Run `fn(dir)` with a private temporary folder (mode `0700`) that is removed when `fn` settles, whether it returns or throws. Needs `permissions.disk.temp` |
| `disk.space(path)` | Free and total bytes of the filesystem that holds a readable folder |
| `disk.usage(path)` | `{ bytes, max, measuredAt }` for a write folder, from the last sample |

Every path is resolved against the app folder and then checked against the declared folders, after symlinks are followed. A link that points out of a declared folder is refused.

### Temporary folders

`disk.temp(fn)` is for work you want gone afterwards, such as rendering a file with a program. Inside `fn`, the folder is readable and writable by `disk` and is a valid `cwd` for `system.run`. After `fn` settles it is removed, and a write to it fails. At start, folders named `mainsdk-<project>-*` that are older than 24 hours are removed, so a crash does not leave them; other projects' folders are not touched.

```ts title="lib/render.ts"
import { join } from "node:path";
import { disk, system } from "mainsdk";

export function wordCount(text: string) {
  return disk.temp(async (dir) => {
    await disk.write(join(dir, "a.txt"), text);
    const out = await system.run("wc", ["-w", "a.txt"], { cwd: dir });
    return Number.parseInt(out.stdout, 10);
  });
}
```

This needs `temp: true` and `processes: [wc]` under `permissions`.

### Size limits and free space

A write that would take a folder past its `max` fails with `MAIN-PERM-0006` (507) before anything is written. While free space is under `reserve`, writes fail with `MAIN-PERM-0007` (507), `health()` lists `disk` as degraded, and reads and removes keep working. Sizes are sampled about every minute and the SDK adds what it writes in between, so a folder's size is approximate: other processes can write between samples. `mainsdk info` shows each write folder's size, limit and free space.

## Examples

### Cache an upstream answer

```ts title="lib/rates.ts"
import { disk, http } from "mainsdk";

export async function rates() {
  try {
    return JSON.parse(await disk.readText(".cache/rates.json"));
  } catch {
    const fresh = await http.json("https://api.partner.com/v1/rates");
    await disk.write(".cache/rates.json", JSON.stringify(fresh));
    return fresh;
  }
}
```

### Build a list of content pages

```ts title="scripts/index-content.ts"
import { disk } from "mainsdk";

const files = (await disk.list("content")).filter((f) => f.endsWith(".md"));
await disk.write("out/index.json", JSON.stringify(files));
```

The script needs `content` under `disk.read` and `out` under `disk.write`.

### Find out what a declared folder is used for

Run discovery to list every folder the code touches, the file and line that touch it, and whether `main.yaml` declares it:

```bash
npx mainsdk discover
```

See [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses).

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-PERM-0001` | `mainsdk check` found `node:fs` (or `node:os`, `node:child_process`) imported outside a driver | Use `disk` for files |
| `MAIN-PERM-0002` | A path outside the declared folders was used (403, enforce mode) | Declare the folder under `permissions.disk`, or remove the code that asks |
| `MAIN-PERM-0003` | `mainsdk check` found a folder the code uses that `permissions` doesn't declare | Declare it; `npx mainsdk discover` prints the lines |
| `MAIN-PERM-0006` | A write would take a folder past its `max` (507); nothing was written | Remove files, or raise `max` under `permissions.disk.write` |
| `MAIN-PERM-0007` | Free space is under `permissions.disk.reserve` (507) | Free space on the disk, or lower `reserve` if the margin is too wide |
| `MAIN-PERM-0008` | `mainsdk check` found an invalid `max` or `reserve`, a `reserve` over 50%, or `temp` given a path | Write a size such as `2GB` or a percentage such as `5%`; `temp` is `true` or `{ max }` |

## Swapping drivers

`disk` has no driver to swap. It always uses the real filesystem of the machine the app runs on, and the folders you declare are the whole surface. To store files for users, or to keep files off the machine, use [`files`](https://mainsdk.dev/docs/interfaces/files), which does have drivers. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`createTestApp()` from [`@mainsdk/testing`](https://mainsdk.dev/docs/interfaces/testing) reads your `permissions` and uses the real disk, relative to the folder the test runs in. Use a folder the test owns, and remove what it writes.

```ts title="lib/cache.test.ts"
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { disk } from "mainsdk";

it("app-permissions AC1 refuses a folder that is not declared", async () => {
  createTestApp({ yaml: "schema: 1\nproject: t\npermissions:\n  disk:\n    write: [.cache]\n" });
  await disk.write(".cache/a.txt", "x");
  assert.equal(await disk.readText(".cache/a.txt"), "x");
  await assert.rejects(disk.readText("/etc/hosts"), { code: "MAIN-PERM-0002" });
  await disk.remove(".cache");
});
```

The test app's `permissions.decisions()` lists each decision with its file and line. A fake disk is planned.

## Security and privacy

- Deny by default. Only the folders named under `permissions.disk` are reachable.
- Symlinks are resolved before the check, so a link cannot lead out of a declared folder.
- Every use is recorded as a decision with the calling file and line. `mainsdk info` lists the ones the app made.
- Writes are atomic and cannot fill the disk past `reserve` or a folder past its `max`.
- No secrets are read from disk by this interface. Keep them in [`secrets`](https://mainsdk.dev/docs/interfaces/secrets).
- Free on every plan.

## FAQ

**Can a browser read `disk`?** No. It runs on the server only.

**Where do user uploads go?** To [`files`](https://mainsdk.dev/docs/interfaces/files). `disk` is for the app's own folders.

**Does `observe` let everything through?** Yes, and it records each use. Read the decisions, add the folders the app needs, then switch to `enforce`.

**Why does `write` also allow reads?** A folder the app writes is one it reads back, such as a cache. Declaring it twice is not needed.

## Next

- [Server facts and programs](https://mainsdk.dev/docs/interfaces/system): the operating system facts and programs the app may use.
- [Network permissions](https://mainsdk.dev/docs/interfaces/network): the outbound side of the same permission model.
- [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses): run discovery and write the permissions block.
