Neon Pilot Download

Filesystem Authority

Neon Pilot should route host-owned filesystem access through a shared Filesystem Authority, the same way host-owned process execution routes through the shared process launcher. The goal is one product boundary for workspace files, extension storage files, artifacts, temp workspaces, archive extraction, and future command sandbox root grants.

Problem

The risky shape is trusted code acting on untrusted path strings:

path.resolve(root, input).startsWith(root) only validates a string. It does not pin the opened file, protect against symlink retargeting between check and use, reject hardlinked aliases, or verify that atomic writes landed where intended. Duplicating those checks across features creates a security boundary made of vibes. Bad boundary. Into the volcano.

Finished-state principle

Any file operation whose path is addressed by a user, agent, extension, imported archive, or external protocol goes through the Filesystem Authority. Callers receive capabilities to scoped roots, not ambient filesystem power.

const workspace = await ctx.filesystem.requestRoot({
  subject: ctx.subject,
  root: { kind: 'workspace', id: conversationId, path: cwd },
  access: ['read', 'write'],
  reason: 'apply model edit',
});

await workspace.writeText('notes/today.md', text);

Raw node:fs remains acceptable for code-owned internal implementation details, migrations, tests, and low-level backends. It is not acceptable for scoped workspace/extension/user-facing paths once this boundary exists.

Layering

extension/core call site
        │
        ▼
FileSystemAuthority  ── root registry, subject identity, grants
        │
        ▼
FileSystemPolicy     ── allow/deny/ask decisions
        │
        ▼
FileSystemHooks      ── extension and core interception points
        │
        ▼
ScopedFileSystem     ── read/write/list/move/remove/archive APIs
        │
        ▼
FilesystemBackend    ── current Node backend; fs-safe can be plugged in later
        │
        ▼
node filesystem

The backend is deliberately hidden behind our interface. The first implementation uses Node filesystem primitives with a single scoped boundary. @openclaw/fs-safe remains a future backend/plugin candidate, not a dependency or public API.

Core types

type FileSystemSubject =
  | { type: 'core'; id: string }
  | { type: 'agent-run'; conversationId: string; runId: string }
  | { type: 'extension'; extensionId: string }
  | { type: 'automation'; taskId: string };

type FileRootKind = 'workspace' | 'extension-storage' | 'artifact' | 'temp' | 'knowledge' | 'downloads' | 'secret';

interface FileRootDescriptor {
  kind: FileRootKind;
  id: string;
  path: string; // absolute host path, resolved by host only
  displayName?: string;
  labels?: Record<string, string>;
}

type FileAccess = 'read' | 'write' | 'delete' | 'list' | 'move' | 'archive' | 'watch' | 'metadata';

interface RequestRootInput {
  subject: FileSystemSubject;
  root: FileRootDescriptor;
  access: FileAccess[];
  reason: string;
  policy?: {
    symlinks?: 'reject' | 'allow-in-root';
    hardlinks?: 'reject' | 'allow';
    maxFileBytes?: number;
    allowedGlobs?: string[];
    deniedGlobs?: string[];
  };
}

interface ScopedFileSystem {
  readonly root: FileRootDescriptor;
  readonly subject: FileSystemSubject;
  readBytes(path: string, options?: ReadOptions): Promise<Uint8Array>;
  readText(path: string, options?: ReadTextOptions): Promise<string>;
  writeBytes(path: string, data: Uint8Array, options?: WriteOptions): Promise<void>;
  writeText(path: string, data: string, options?: WriteOptions): Promise<void>;
  readJson<T>(path: string, options?: JsonReadOptions): Promise<T>;
  writeJson(path: string, value: unknown, options?: JsonWriteOptions): Promise<void>;
  list(path?: string, options?: ListOptions): Promise<FileEntry[]>;
  stat(path: string): Promise<FileStat>;
  move(from: string, to: string, options?: MoveOptions): Promise<void>;
  copyIn(to: string, absoluteSource: string, options?: CopyInOptions): Promise<void>;
  remove(path: string, options?: RemoveOptions): Promise<void>;
  extractArchive(archivePath: string, destination: string, options?: ArchiveOptions): Promise<ArchiveExtractResult>;
  createTempWorkspace(options?: TempWorkspaceOptions): Promise<ScopedFileSystem>;
}

The public extension SDK should expose ctx.filesystem and keep ctx.workspace as a convenience wrapper over ctx.filesystem.requestRoot({ root: currentWorkspace }).

Policy and hooks

Policy answers whether a subject may do an operation. Hooks observe or wrap the operation. They should mirror the process-wrapper model, but file hooks need typed decisions because file operations are smaller and more numerous than process launches.

type FileOperation = 'read' | 'write' | 'delete' | 'list' | 'move' | 'copy-in' | 'archive-extract' | 'watch' | 'metadata';

type FilePolicyDecision =
  | { type: 'allow' }
  | { type: 'deny'; reason: string }
  | { type: 'ask-user'; prompt: string; default?: 'deny' | 'allow' };

interface FileOperationContext {
  subject: FileSystemSubject;
  root: FileRootDescriptor;
  operation: FileOperation;
  relativePath?: string;
  destinationPath?: string;
  access: FileAccess[];
  reason: string;
  requestId: string;
}

interface FileSystemPolicy {
  decide(ctx: FileOperationContext): Promise<FilePolicyDecision> | FilePolicyDecision;
}

interface FileSystemHook {
  before?(ctx: FileOperationContext): Promise<FilePolicyDecision | void> | FilePolicyDecision | void;
  after?(ctx: FileOperationContext & { outcome: 'success' | 'failure'; error?: unknown }): Promise<void> | void;
}

Rules:

Root registry and grants

The authority owns all root construction. Callers do not pass arbitrary absolute paths around after the root is granted.

Initial root kinds:

Kind Owner Default policy
workspace conversation/workspace runtime read/list, write/delete only for agent/tool surfaces that already have write authority
extension-storage extension host private to one extension, read/write, no cross-extension access
artifact artifacts extension/core export flow write through artifact APIs, read for rendering/export
temp runtime private 0700 scratch, cleaned by lifecycle owner
knowledge knowledge extension explicit read/write grants; never ambient for arbitrary extensions
downloads browser/import flows staged writes then explicit copy into workspace/artifact roots
secret secrets/credentials surfaces opt-in only, strict modes, no list by default

Grants become the shared language with command sandboxing:

interface AuthorityGrant {
  subject: FileSystemSubject;
  root: FileRootDescriptor;
  access: FileAccess[];
  expiresAt?: string;
  source: 'manifest' | 'conversation' | 'user-approval' | 'core';
}

A bash sandbox wrapper can consume the same grants to decide which roots to mount or expose. Direct file APIs and process execution then agree on the same authority model.

Extension manifest and SDK direction

Existing permissions are intent declarations. The finished state should make filesystem intent explicit:

{
  "permissions": ["filesystem:workspace:read", "filesystem:workspace:write", "filesystem:extension-storage:readwrite"]
}

Suggested SDK:

await ctx.filesystem.workspace({ access: ['read'], reason: 'index files' });
await ctx.filesystem.extensionStorage({ access: ['read', 'write'], reason: 'cache API result' });
await ctx.filesystem.temp({ reason: 'render preview' });

ctx.storage remains SQLite key-value state. ctx.filesystem.extensionStorage() is for real files/blobs managed under the extension's private root.

Backend behavior

The backend should use defaults that match product safety, not maximum compatibility. Today that means the Node backend owns root scoping, access checks, policy hooks, audit events, and atomic writes where it performs writes. A future fs-safe backend should preserve the same authority contract and can harden these primitives further:

If a backend cannot express a policy cleanly, the authority owns the missing product behavior above it rather than leaking backend quirks upward.

Events, audit, and UI

Every mutating operation should emit a host event and an audit record:

interface FileAuditEvent {
  timestamp: string;
  requestId: string;
  subject: FileSystemSubject;
  root: Pick<FileRootDescriptor, 'kind' | 'id' | 'displayName'>;
  operation: FileOperation;
  relativePath?: string;
  destinationPath?: string;
  outcome: 'success' | 'denied' | 'failed';
  policySource?: string;
  wrapperIds?: string[];
}

This should feed existing workspace file events (host:workspaceFiles) instead of creating parallel realities. Tool UI should show when a filesystem wrapper/policy handled an operation, matching the sandboxing visibility contract.

Migration target

Converge these surfaces on the authority:

Avoid a half migration where some paths use the authority and equivalent paths bypass it. The repo should eventually have a lint/build guard for direct node:fs use in extension backend code and for workspace-facing server modules, with explicit low-level-backend allowlists.

Non-goals

Implementation phases aimed at the finished state

The implementation can land in slices, but each slice should preserve the final architecture:

  1. Add packages/core/src/filesystem-authority interfaces, errors, audit event types, and an in-process authority implementation.
  2. Add FsSafeBackend and package dependency.
  3. Wire extension backend ctx.filesystem; reimplement ctx.workspace on top of it.
  4. Move server workspace/file-explorer APIs onto the authority.
  5. Move artifact/temp/archive/knowledge file operations onto dedicated root kinds.
  6. Feed file audit events into the host event bus and workspace file subscriptions.
  7. Connect process wrappers/sandboxing to authority grants.
  8. Add build/lint guards against new direct scoped-path node:fs usage outside low-level backends.

The target is boring: one boundary, one policy vocabulary, one audit trail. Boring is good. Boring means the ogre is cooked all the way through.