Skip to main content

Storage interface

Storage is the filesystem abstraction every reader and writer of ~/.ethos/ takes in its constructor. Production code wires FsStorage; tests wire InMemoryStorage; the ScopedStorage decorator enforces a per-personality fs_reach allowlist.

Source

Interface in packages/types/src/storage.ts. Implementations in packages/storage-fs/src/.

Storage

Signature

import type {
Storage,
StorageDirEntry,
StorageRemoveOptions,
StorageWriteOptions,
} from '@ethosagent/types';

export interface Storage {
read(path: string): Promise<string | null>;
exists(path: string): Promise<boolean>;
mtime(path: string): Promise<number | null>;
list(dir: string): Promise<string[]>;
listEntries(dir: string): Promise<StorageDirEntry[]>;
write(path: string, content: string, opts?: StorageWriteOptions): Promise<void>;
append(path: string, content: string): Promise<void>;
writeAtomic(path: string, content: string, opts?: StorageWriteOptions): Promise<void>;
mkdir(dir: string): Promise<void>;
remove(path: string, opts?: StorageRemoveOptions): Promise<void>;
rename(from: string, to: string): Promise<void>;
}

Methods

MethodReturnsDescription
read(path)string | nullRead utf-8 text. Returns null if the file does not exist.
exists(path)booleanTrue if the path resolves to a file or directory.
mtime(path)number | nullModification time in epoch milliseconds, or null if absent.
list(dir)string[]Immediate children (names only). Empty array if missing.
listEntries(dir)StorageDirEntry[]Same as list, with { name, isDir }.
write(path, content, opts?)voidWrite utf-8 text. Parent dir must already exist. opts.mode applies POSIX permissions atomically.
append(path, content)voidAppend utf-8 text. Creates the file if missing.
writeAtomic(path, content, opts?)voidWrite to <path>.tmp.<pid>, then rename. Use for files where a partial write would corrupt state (config, keys, audit).
mkdir(dir)voidRecursive directory create. No-op if the directory already exists.
remove(path, opts?)voidDelete. opts.recursive enables rm -rf semantics.
rename(from, to)voidRename or move.

Error semantics

  • read, exists, and mtime return null (or false for exists) for missing paths. Missing-file is the common case, not an exception.
  • Every other method throws on failure.
  • ScopedStorage throws BoundaryError when a path lies outside the allowlist; consumers should catch and translate to user-facing tool errors.

Notes

  • All paths are absolute. The interface does not manage a root — consumers compute paths (typically via ethosDir() helpers) and pass them in.
  • writeAtomic is a separate method, not a flag on write. The split prevents the "did the writer remember?" footgun.
  • StorageWriteOptions.mode is POSIX only. On Windows the value is partially honoured per fs.writeFile semantics.

FsStorage

Signature

import { FsStorage } from '@ethosagent/storage-fs';

const storage = new FsStorage();

Concrete implementation backed by node:fs/promises. Construct with no arguments. Use in every production wiring (CLI, web-api, gateway).

Notes

  • writeAtomic writes to <path>.tmp.<pid> and renames into place. On crash the temp file is left behind; consumers can clean up on startup if it matters.
  • POSIX mode is applied via fs.chmod before the rename, so the final file has the requested permissions from the instant it exists at the destination path.

InMemoryStorage

Signature

import { InMemoryStorage } from '@ethosagent/storage-fs';

const storage = new InMemoryStorage();
await storage.write('/etc/foo', 'hello');

In-memory Storage for tests. Populate fixtures via write() — no tmpdir scaffolding required. Same surface as FsStorage, so tests work against the interface, not the implementation.

Notes

  • Paths are stored as keys in a Map<string, string>. Directories are implicit (any prefix is treated as a directory).
  • mtime is tracked per-write; reads return the most recent write time.

ScopedStorage

Signature

import { ScopedStorage, type ScopedStorageScope } from '@ethosagent/storage-fs';

const scoped = new ScopedStorage(inner, {
read: ['/home/me/.ethos/personalities/engineer/', '/home/me/repo/'],
write: ['/home/me/.ethos/personalities/engineer/'],
alwaysDeny: ['/home/me/.ssh/', '/etc/'],
});

Decorator that enforces a per-personality read/write allowlist plus a universal always-deny floor.

Members

FieldTypeDescription
innerStorageUnderlying storage being decorated.
scope.readreadonly string[]Path prefixes that may be read.
scope.writereadonly string[]Path prefixes that may be mutated.
scope.alwaysDenyreadonly string[] | undefinedUniversal deny floor — checked before allow rules. Built-ins include ~/.ssh/, ~/.aws/, /etc/.

Check order

For every call:

  1. alwaysDeny match → BoundaryError with reason 'always-deny floor'.
  2. No read / write prefix match → BoundaryError.
  3. Otherwise → delegate to inner.

Deny always wins over allow.

Notes

  • Prefixes are matched literally — no glob expansion. Pass trailing-slash directory prefixes so /a/b does not also match /a/bc/.
  • ScopedStorage is built per turn by AgentLoop from personality.fs_reach. Tools receive it via ToolContext.storage.

BoundaryError

Signature

import { BoundaryError } from '@ethosagent/types';

throw new BoundaryError('read', '/etc/passwd', ['/home/me/.ethos/']);

Members

FieldTypeDescription
code'storage-boundary' (literal)Stable error class. Switch-statement safe.
kind'read' | 'write'Which operation was attempted.
pathstringThe rejected absolute path.
name'BoundaryError'JS error name.
messagestring"<kind> not permitted: <path> not in [allowed list] (<why>)"

Notes

  • Caught by extensions/tools-file/src/ and translated into a user-facing tool error so the LLM sees a structured rejection rather than a stack trace.
  • code is also exported as a discriminant: err.code === 'storage-boundary' reliably identifies the class even across realm boundaries.

Used by

ConsumerRole
apps/ethos/src/wiring.tsConstructs FsStorage and threads it into every consumer.
packages/core/src/agent-loop.tsWraps the base Storage with ScopedStorage per turn and passes it via ToolContext.storage.
extensions/personalities/src/index.tsFilePersonalityRegistry uses the base Storage to read personality directories.
extensions/memory-markdown/src/index.tsReads / writes MEMORY.md and USER.md.
extensions/tools-file/src/Tool execution; catches BoundaryError and translates.
extensions/observability-sqlite/src/Uses raw node:fs for SQLite (allowed exception).
packages/storage-fs/src/__tests__/InMemoryStorage powers the conformance suite.

See also