Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/add-agent-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@nestm/storage': minor
---

Add a backend-neutral `StorageWorkspace` capability and optional AI SDK 7 tool
adapter. Workspaces expose only canonical mount-relative paths, enforce
permissions and resource limits, hide provider coordinates and cursors, and use
atomic create/ETag mutation preconditions. S3 now advertises and implements the
conditional mutation primitives used by writable workspaces.

Harden local filesystem workspace reads and conditional mutations against
symlink aliases. Moves retain their create-only destination whenever source
deletion cannot be confirmed, avoiding data loss after provider or
post-operation hook ambiguity.

Fix cross-store sync so `destinationPrefix` is applied to uploaded keys as well
as pruning, keeping every mutation inside the selected destination scope.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
dist/
coverage/
node_modules/
references/
pnpm-lock.yaml
.changeset/pre.json
184 changes: 182 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# @nestm/storage

Framework-neutral storage clients with NestJS 12 integration, named stores,
explicit streaming I/O, cross-store workflows, and an optional guarded HTTP
gateway.
explicit streaming I/O, capability-scoped agent workspaces, cross-store
workflows, and an optional guarded HTTP gateway.

The package uses [`files-sdk`](https://github.com/haydenbleasel/files-sdk) as
its provider engine, but owns the API injected into Nest applications. Provider
Expand Down Expand Up @@ -88,6 +88,186 @@ storage errors and operation types, and `StorageUploadControl`. It has no NestJS
runtime or declaration imports. Provider adapters remain available through
`@nestm/storage/files-sdk`.

## Mounted agent workspaces

`@nestm/storage/workspace` turns a `StorageClient` into a narrow capability for
one logical directory. The mount is virtual: the same API works over S3, a
filesystem driver, or another storage backend without exposing the provider,
bucket, filesystem root, raw cursor, or internal prefix to its caller.

```mermaid
flowchart LR
A["Trusted application context"] -->|"store + opaque prefix + policy"| W["StorageWorkspace"]
W --> C["StorageClient"]
C --> D["S3 / filesystem / other driver"]
W --> T["AI SDK workspace tools"]
T --> G["ToolLoopAgent"]
```

Only trusted application code chooses the mount prefix. Every path accepted by
the workspace is a canonical, relative POSIX path. Absolute paths, backslashes,
control characters, repeated separators, and `.` or `..` segments are rejected
rather than normalized. Keys and provider cursors returned by a driver are also
checked before they are converted back to logical paths.

```ts
import { mountStorageWorkspace } from '@nestm/storage/workspace';

const workspace = mountStorageWorkspace(agentFiles, {
// Use an opaque server-derived run id, never a value selected by the model.
prefix: `workspaces/${runId}`,
permissions: [
'list',
'read',
'search',
'create',
'replace',
'copy',
'move',
'delete',
],
limits: {
maxReadBytes: 1024 * 1024,
maxWriteBytes: 1024 * 1024,
maxPageSize: 100,
maxSearchResults: 100,
maxSearchScan: 1000,
},
});

const created = await workspace.writeFile(
'src/main.ts',
'export const ready = true;\n',
{ mode: 'create', contentType: 'text/typescript' },
);

if (created.etag === undefined) {
throw new Error('This backend cannot safely replace the object.');
}

await workspace.writeFile('src/main.ts', 'export const ready = false;\n', {
mode: 'replace',
etag: created.etag,
contentType: 'text/typescript',
});
```

Create, replace, and delete are conditional operations. A driver that cannot
enforce the requested not-exists or ETag precondition fails with
`NOT_SUPPORTED`; the workspace never substitutes an `exists()`/`head()` check
followed by an unconditional mutation. Reads enforce their byte ceiling while
consuming the stream, and list/search results are bounded. Search supports
exact, substring, and workspace-coordinate glob matching, but no caller-supplied
regular expressions.

Move is implemented as create-only copy followed by ETag-conditional source
delete. If source deletion cannot be confirmed, the destination is retained and
the call returns `CONFLICT`; inspect both logical paths before retrying. This
preserves at least one copy across provider timeouts and post-operation hook
failures, but does not pretend a multi-object move is transactionally atomic.

A child mount may further restrict a directory, permissions, or limits, but it
cannot widen any of them:

```ts
const readOnlySource = workspace.mount('src', {
permissions: ['list', 'read', 'search'],
limits: { maxReadBytes: 256 * 1024 },
});
```

### AI SDK and NestJS composition

Install AI SDK 7 and Zod only in applications that use the optional adapter:

```sh
pnpm add ai@^7 zod@^4
```

`files-sdk` 2.2.x still declares an optional `ai@^6` peer for its own adapter,
so some package managers may print a peer warning when AI SDK 7 is installed.
This package does not import that adapter; `@nestm/storage/ai-sdk` targets AI
SDK 7 directly.

`@nestm/storage/ai-sdk` converts an already-mounted workspace to an ordinary
upstream `ToolSet`. It does not import NestJS or `@nestm/ai-sdk`; the application
composes the tool set through the AI module's existing named-toolset factory.
For a tenant or run selected per request, make both factories request-scoped and
derive the mount coordinate from authenticated host context:

```ts
import { Module, Scope } from '@nestjs/common';
import { AiSdkModule, AiSdkService, getAiToolsetToken } from '@nestm/ai-sdk';
import { getStorageToken, type StorageClient } from '@nestm/storage';
import { createAiSdkWorkspaceTools } from '@nestm/storage/ai-sdk';
import { mountStorageWorkspace } from '@nestm/storage/workspace';
import type { ToolSet } from 'ai';

@Module({
imports: [
AppStorageModule,
WorkspaceContextModule,
AiSdkModule.forFeature({
imports: [AppStorageModule, WorkspaceContextModule],
toolsets: [
{
name: 'workspace',
scope: Scope.REQUEST,
inject: [getStorageToken('agent-files'), WorkspaceContext],
useFactory: (storage: StorageClient, context: WorkspaceContext) =>
createAiSdkWorkspaceTools({
workspace: mountStorageWorkspace(storage, {
// A validated, opaque coordinate from trusted auth/run state.
// It is never accepted from a prompt or tool input.
prefix: context.storagePrefix,
permissions: [
'list',
'read',
'search',
'create',
'replace',
'copy',
'move',
'delete',
],
}),
}),
},
],
agents: [
{
name: 'workspace-agent',
scope: Scope.REQUEST,
inject: [AiSdkService, getAiToolsetToken('workspace')],
useFactory: (ai: AiSdkService, tools: ToolSet) => ({
model: ai.languageModel(),
instructions:
'Use only the mounted workspace tools for file operations.',
tools,
}),
},
],
}),
],
})
export class WorkspaceAgentModule {}
```

The generated set contains only tools allowed by the workspace permissions:
bounded list, stat, UTF-8 read, and search tools plus conditional create,
replace, copy, move, and delete tools when granted. Mutation tools require AI
SDK user approval by default; approval can be configured per tool, but the
workspace capability remains the authorization boundary even when approval is
disabled. The module's `AiSdkService.files()` API is the model provider's file
upload facility and is unrelated to storage workspaces.

This logical confinement is sufficient for a `ToolLoopAgent` whose only file
capabilities are these tools. It cannot constrain a coding harness that already
has shell, `node:fs`, or subprocess access. For Codex/Claude-style harnesses,
materialize the workspace into a per-session container or VM, mount only that
directory, run the harness there, and synchronize reviewed changes back through
`StorageWorkspace`. A working directory alone is not a sandbox.

## Configure named stores

Use the package-owned S3 factory when applicable. For other providers, create a
Expand Down
40 changes: 40 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,43 @@ the validated `head` or with an immutable provider version. Ordinary `copy`
does not protect against a staging-key replay between validation and copy.
Conditional promotion keeps the staging source; delete it only after the
application's metadata transaction commits.

## Workspace security boundary

`StorageWorkspace` is a narrowing capability for storage operations. Construct
it from trusted application context, keep the underlying `StorageClient` and
mount prefix private, and pass only the workspace or its AI tool set to
untrusted agent code. A tenant id, run id, prefix, provider cursor, snapshot id,
or fork id supplied by a model is not a safe mount coordinate.

The workspace accepts only canonical mount-relative POSIX paths and rechecks
every key returned by a driver before unscoping it. Its cursors are opaque and
bound to the mount and query. Permissions, byte limits, result limits, and
conditional mutation preconditions are enforced inside the capability; tool
omission and user approval are additional workflow controls, not the
authorization boundary.

Conditional mutations can still have an ambiguous outcome when a remote
provider commits and then loses or violates its response, or when a configured
post-operation plugin fails after the driver has committed. The API fails
closed in that case: inspect the logical destination and reconcile it before
retrying. Create-only and ETag preconditions prevent a blind retry from
silently overwriting a different object, but they cannot make a multi-object
move transactionally atomic.

This guarantee covers calls made through `StorageWorkspace`. It does not
confine arbitrary `node:fs`, shell, subprocess, or native-code access in the
same process. A coding harness with built-in shell or filesystem tools must run
inside an OS sandbox (container, VM, or equivalent) that exposes only a
materialized workspace. Setting `cwd` to a workspace directory is not
isolation.

For local filesystem storage, use a dedicated service-owned root and do not let
another untrusted process mutate its directory tree concurrently. High-level
Node filesystem checks reject symlinks and hard-linked object files in existing
workspace read and mutation paths, including metadata sidecars, but cannot
provide a race-proof boundary against an actor that can replace path components
between validation and use. For that threat model, mount only the workspace
into a separate UID/container/VM and synchronize approved results back through
storage. The local adapter also commits the body and metadata sidecar as two
files; a process crash between their atomic renames can require reconciliation.
25 changes: 22 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@nestm/storage",
"version": "0.1.0-alpha.6",
"description": "Framework-neutral storage clients with NestJS 12 integration, named stores, streaming I/O, and an optional guarded HTTP gateway.",
"description": "Framework-neutral storage clients, capability-scoped agent workspaces, NestJS 12 integration, and guarded HTTP access.",
"license": "MIT",
"author": "Kauan Guesser",
"type": "module",
Expand All @@ -23,6 +23,14 @@
"types": "./dist/core/index.d.ts",
"import": "./dist/core/index.js"
},
"./workspace": {
"types": "./dist/workspace/index.d.ts",
"import": "./dist/workspace/index.js"
},
"./ai-sdk": {
"types": "./dist/ai-sdk/index.d.ts",
"import": "./dist/ai-sdk/index.js"
},
"./files-sdk": {
"types": "./dist/files-sdk/index.d.ts",
"import": "./dist/files-sdk/index.js"
Expand Down Expand Up @@ -71,6 +79,7 @@
"nestjs-12",
"storage",
"object-storage",
"agent-workspace",
"s3",
"gcs",
"azure",
Expand Down Expand Up @@ -107,8 +116,10 @@
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@nestjs/common": "^12.0.0-alpha.5",
"@nestjs/core": "^12.0.0-alpha.5",
"ai": ">=7.0.0 <8.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"zod": "^4.1.8"
},
"peerDependenciesMeta": {
"@aws-sdk/client-s3": {
Expand All @@ -129,11 +140,17 @@
"@nestjs/core": {
"optional": true
},
"ai": {
"optional": true
},
"reflect-metadata": {
"optional": true
},
"rxjs": {
"optional": true
},
"zod": {
"optional": true
}
},
"devDependencies": {
Expand All @@ -150,6 +167,7 @@
"@types/node": "26.1.2",
"@types/supertest": "7.2.1",
"@vitest/coverage-v8": "4.1.10",
"ai": "7.0.52",
"fastify": "5.11.2",
"oxlint": "1.77.0",
"prettier": "3.9.6",
Expand All @@ -158,6 +176,7 @@
"rxjs": "7.8.2",
"supertest": "7.2.2",
"typescript": "7.0.2",
"vitest": "4.1.10"
"vitest": "4.1.10",
"zod": "4.4.3"
}
}
Loading