Workspace
Prism Workspace gives an agent a directory of its own to keep its work in, in PHP, TypeScript and Python. Every path is checked before it touches the disk, so a path the model wrote cannot read or write outside the workspace.
composer require particle-academy/prism-workspace
npm install @particle-academy/prism-workspace
pip install prism-ai-workspace
The PHP package requires PHP 8.2+ and Laravel 12.69+ or 13.30+, and builds each workspace on a Laravel filesystem disk (local, S3 or any other Flysystem driver). The TypeScript package (Node.js 22+) and the Python package (3.10+) have no runtime dependencies and work on the local filesystem.
Workspace is a storage API for your application code. It does not register model tools and it never executes files. To let a model read or write files, define your own tools that call these methods.
Configuration#
PHP works with no configuration: workspaces are created under
storage/app/workspaces on the local disk. To change that, publish
config/prism-workspace.php:
php artisan vendor:publish --tag=prism-workspace-config
| Key | Default | Env | Meaning |
|---|---|---|---|
disk |
local |
PRISM_WORKSPACE_DISK |
Any disk from config/filesystems.php. |
root |
workspaces |
PRISM_WORKSPACE_ROOT |
Directory on that disk that holds one subdirectory per owner. |
max_path_length |
1024 |
— | Maximum path length, in bytes. |
max_segment_length |
255 |
— | Maximum length of one path segment, in bytes. |
windows_max_path |
259 |
— | Maximum full path length on Windows local disks. Set null if long paths are enabled. |
authorize |
false |
PRISM_WORKSPACE_AUTHORIZE |
Check a Laravel Gate before every operation. See Authorization. |
gate_prefix |
workspace |
PRISM_WORKSPACE_GATE_PREFIX |
Prefix of the Gate abilities. |
TypeScript and Python take the same settings as constructor arguments: a base
directory, the owner, and the options guard (a PathGuard with custom
limits), authorize and windowsMaxPath / windows_max_path.
Open a workspace#
A workspace belongs to an owner. The same owner always resolves to the same directory, so a later request or a fresh worker finds the files an earlier run wrote.
use Prism\Workspace\Facades\PrismWorkspace; $workspace = PrismWorkspace::for($session); // a Harness Session, model, WorkspaceOwner or string $workspace->write('reports/q1.md', $content); $markdown = $workspace->read('reports/q1.md'); foreach ($workspace->list('reports') as $entry) { echo $entry->path, PHP_EOL; }
import { Workspace } from '@particle-academy/prism-workspace'; const workspace = new Workspace('/srv/agent-workspaces', session); await workspace.write('reports/q1.md', content); const markdown = await workspace.read('reports/q1.md'); for await (const entry of workspace.list('reports')) { console.log(entry.path); }
from prism_workspace import Workspace workspace = Workspace("/srv/agent-workspaces", session) await workspace.write("reports/q1.md", content) markdown = await workspace.read("reports/q1.md") async for entry in workspace.list("reports"): print(entry.path)
Python's Workspace is async; SyncWorkspace has the same methods and blocks.
Both take the same constructor arguments.
Owners#
An owner can be:
- an object implementing
WorkspaceOwner—workspaceKey(): stringin PHP and TypeScript,workspace_key() -> strin Python; - any object with a
key()method returning a string, such as a Harness session; - an Eloquent model (PHP only), addressed by its morph class and key;
- a non-empty string, such as a job id.
The key must be stable: if it changes between requests, the owner gets a new,
empty workspace. An owner that resolves to no key raises WorkspaceFailed with
workspace_owner_not_addressable.
use Prism\Workspace\Contracts\WorkspaceOwner; final class Project implements WorkspaceOwner { public function __construct(private int $id) {} public function workspaceKey(): string { return "project:{$this->id}"; } }
import type { WorkspaceOwner } from '@particle-academy/prism-workspace'; class Project implements WorkspaceOwner { constructor(private readonly id: number) {} workspaceKey(): string { return `project:${this.id}`; } }
class Project: def __init__(self, id: int) -> None: self.id = id def workspace_key(self) -> str: return f"project:{self.id}"
The directory name is <slug>-<hash>: a readable slug of the key (up to 48
characters) followed by the first 16 hex characters of its SHA-256. Owners whose
keys differ only in case get different directories. The address is the same in
all three languages, so they can share a base directory.
Methods#
| PHP | TypeScript | Python | Returns |
|---|---|---|---|
exists($path) |
exists(path) |
exists(path) |
bool |
read($path) |
read(path) |
read(path) |
file contents (TS and Python decode UTF-8) |
| — | readBytes(path) |
read_bytes(path) |
raw bytes |
readStream($path) |
readStream(path) |
read_stream(path, chunk_size=65536) |
PHP resource / Node Readable / iterator of byte chunks |
write($path, $contents) |
write(path, contents) |
write(path, contents) |
the workspace |
writeStream($path, $resource) |
writeStream(path, source) |
write_stream(path, source) |
the workspace |
append($path, $contents) |
append(path, contents) |
append(path, contents) |
the workspace |
copy($from, $to) |
copy(from, to) |
copy(source, destination) |
the workspace |
move($from, $to) |
move(from, to) |
move(source, destination) |
the workspace |
delete($path) |
delete(path) |
delete(path) |
the workspace |
makeDirectory($path) |
makeDirectory(path) |
make_directory(path) |
the workspace |
deleteDirectory($path) |
deleteDirectory(path) |
delete_directory(path) |
the workspace |
size($path) |
size(path) |
size(path) |
bytes |
lastModified($path) |
lastModified(path) |
last_modified(path) |
Unix timestamp, seconds |
list($directory = '', $recursive = true) |
list(directory?, recursive?) |
list(directory="", recursive=True) |
entries, streamed |
clear() |
clear() |
clear() |
the workspace; deletes everything inside it |
path($path) |
path(path) |
path(path) |
the guarded, normalised path |
address() |
address |
address |
the owner's directory name |
root() |
root |
root |
absolute directory (PHP returns null for non-local disks) |
disk() |
— | — | the underlying Laravel disk |
PHP's read() returns the file's bytes as a string, so it is safe for binary
files. In TypeScript and Python, use readBytes() / read_bytes() for
anything that is not UTF-8 text; read() would corrupt it. In TypeScript and
Python, paths may be passed as strings or as raw bytes (Uint8Array / bytes).
Python's append() takes text only.
Writes create missing parent directories. copy() guards both paths. delete()
on a file that does not exist is not an error.
`disk()` bypasses the guard
disk() returns the scoped Laravel disk itself. Paths passed to it are not
checked. Never pass a model-supplied path to it.
Listing entries#
list() streams: PHP returns a LazyCollection, TypeScript an async iterator
and Python an iterator (SyncWorkspace) or async iterator (Workspace). A
directory of ten thousand files does not have to be loaded at once, and taking
the first five entries reads only five.
Each entry has:
| PHP / TypeScript | Python | Value |
|---|---|---|
path |
path |
Path relative to the workspace root, with forward slashes. |
isDirectory |
is_directory |
Whether the entry is a directory. |
size |
size |
Size in bytes, or null/None. |
lastModified |
last_modified |
Unix timestamp, or null/None. |
size and lastModified are filled from the listing when the PHP disk reports
them; TypeScript and Python always leave them empty so a listing never costs a
stat per file. Call size() / lastModified() when you need them. PHP and
Python entries also have isFile() / is_file and name() / name.
Path validation#
Paths usually come from model output, so every operation runs the path through a guard before anything else happens. The guard refuses, without touching the disk:
- traversal: any segment starting with
..; - absolute paths, drive letters, UNC and device-namespace paths;
- null bytes, control characters, invalid UTF-8;
- invisible and direction-changing characters, and characters that look like
/or\; - percent-encoded separators and dots (
%2e%2e%2f); - NTFS alternate data streams (
file.txt:hidden), reserved device names (CON,NUL,COM1…), 8.3 short names (PROGRA~1), and segments with a leading or trailing dot or space; ~home expansion;- empty paths and paths over the length limits.
On local disks a second check resolves the real path and refuses a path that
leaves the workspace through a symbolic link or junction, and on Windows one
that exceeds windows_max_path.
A valid path is normalised: backslashes become /, . and empty segments are
removed. path() returns that normalised form without performing an operation.
Authorization#
Path validation keeps every operation inside the workspace. Authorization adds your own rule on top: whether this user or agent may do this operation here. It is off by default.
In PHP, set 'authorize' => true and define Gate abilities named
<gate_prefix>.read, .write, .delete and .list. Each receives the
workspace and the guarded path (null for clear()). In PHP, list() of the
whole workspace (no directory argument) is not checked against the Gate; listing
a subdirectory checks .list. A denial throws Laravel's AuthorizationException.
use App\Models\User; use Illuminate\Support\Facades\Gate; use Prism\Workspace\Workspace; Gate::define('workspace.read', fn (?User $user, Workspace $workspace, ?string $path) => true); Gate::define('workspace.list', fn (?User $user, Workspace $workspace, ?string $path) => true); Gate::define('workspace.write', fn (?User $user, Workspace $workspace, ?string $path) => $path === null || ! str_starts_with($path, 'published/')); Gate::define('workspace.delete', fn (?User $user, Workspace $workspace, ?string $path) => $user !== null);
Make the user parameter nullable
Laravel does not run a Gate callback for a guest unless its first parameter is
nullable (?User). In a queue worker there is no authenticated user, so a
callback written fn (User $user, ...) denies every operation.
In TypeScript and Python, pass an authorize callback. It is called with the
ability (read, write, delete or list), the workspace and the guarded
path (undefined/None for clear() and for listing the root); throw to
deny. In TypeScript it may be async. In Python it is a plain
function, and receives the SyncWorkspace even when you use the async
Workspace.
import { Workspace, type Authorizer } from '@particle-academy/prism-workspace'; const authorize: Authorizer = (ability, _workspace, path) => { if (ability !== 'read' && ability !== 'list' && path?.startsWith('published/')) { throw new Error('Published files are read-only.'); } }; const workspace = new Workspace('/srv/agent-workspaces', session, { authorize });
from prism_workspace import SyncWorkspace, Workspace def authorize(ability: str, workspace: SyncWorkspace, path: str | None) -> None: if ability not in ("read", "list") and path and path.startswith("published/"): raise PermissionError("Published files are read-only.") workspace = Workspace("/srv/agent-workspaces", session, authorize=authorize)
Every operation runs in the same order: guard the path, then authorize, then check where the path leads on disk. Your policy never sees a path that failed the guard.
Errors#
Two exceptions, each with a stable code. Branch on the code, not the message.
| PHP | TypeScript | Python | |
|---|---|---|---|
| Path refused | PathRefused — ->refusal (Refusal enum), ->path |
PathRefused — .code, .path |
PathRefused — .code (Refusal enum), .path |
| Operation failed | WorkspaceFailed — ->fault (Fault enum) |
WorkspaceFailed — .code |
WorkspaceFailed — .fault (Fault enum) |
A refusal means something tried to leave the workspace; treat it as a security event and alert on it. A failure is an ordinary operational problem.
Refusal codes
| Code | Refused because |
|---|---|
path_traverses_outside_workspace |
a segment starts with .. |
path_is_absolute |
starts with /, a backslash or a drive letter (C:) |
path_is_unc_or_device_namespace |
starts with two separators (a UNC or device-namespace path) |
path_contains_null_byte |
null byte |
path_contains_control_character |
control character |
path_contains_invisible_character |
invisible or direction-changing character |
path_contains_separator_homoglyph |
a character that looks like a slash |
path_is_not_valid_utf8 |
invalid UTF-8 |
path_contains_encoded_separator |
percent-encoded separator, dot or similar |
path_contains_alternate_data_stream |
a colon in a segment |
path_is_reserved_device_name |
CON, NUL, COM1, LPT1 and similar |
path_has_edge_dot_or_space |
a segment begins or ends with a dot or space |
path_uses_short_name_alias |
an 8.3 short name such as PROGRA~1 |
path_uses_home_expansion |
a leading ~ segment |
path_is_empty |
empty path |
path_too_long |
over the path length limit |
path_segment_too_long |
a segment over the segment length limit |
path_escapes_workspace_via_link |
the path leaves the workspace through a link |
Failure codes
| Code | Meaning |
|---|---|
workspace_file_missing |
the file does not exist (read, readStream, size, lastModified) |
workspace_write_failed |
a write, append, copy, move or directory creation failed |
workspace_delete_failed |
a delete failed |
workspace_owner_not_addressable |
the owner has no usable key |
use Prism\Workspace\Exceptions\PathRefused; use Prism\Workspace\Exceptions\Refusal; try { $workspace->read($pathFromModel); } catch (PathRefused $refused) { if ($refused->refusal === Refusal::Traversal) { report($refused); } }
import { PathRefused } from '@particle-academy/prism-workspace'; try { await workspace.read(pathFromModel); } catch (error) { if (error instanceof PathRefused && error.code === 'path_traverses_outside_workspace') { console.warn('Escape attempt', error.path); } }
from prism_workspace import PathRefused, Refusal try: await workspace.read(path_from_model) except PathRefused as refused: if refused.code is Refusal.TRAVERSAL: log.warning("Escape attempt: %r", refused.path)
Verify the boundary on your setup#
The guard's test suite proves it on our disks. Your disk driver, root,
network share or case-insensitive volume may behave differently, so the escape
corpus ships with the package: 134 hostile paths, each with the refusal code it
must produce. CorpusRunner writes every one of them into a real workspace and
then searches the directories around the workspace for anything that got out.
use Prism\Workspace\Facades\PrismWorkspace; use Prism\Workspace\Security\CorpusRunner; $report = (new CorpusRunner)->against(PrismWorkspace::for('corpus-check')); if (! $report->passed()) { throw new RuntimeException($report->summary()); }
import { Workspace } from '@particle-academy/prism-workspace'; import { CorpusRunner } from '@particle-academy/prism-workspace/corpus'; const report = await new CorpusRunner().against(new Workspace(base, 'corpus-check')); if (!report.passed()) throw new Error(report.summary());
from prism_workspace import SyncWorkspace from prism_workspace.corpus import CorpusRunner report = CorpusRunner().against(SyncWorkspace(base, "corpus-check")) if not report.passed(): raise RuntimeError(report.summary())
A run is safe against a live workspace: every attempt should be refused, so a
passing run writes nothing. The report lists failures() (attempts that were
accepted, refused with the wrong code, or failed with some other error) and
strays (files found outside the workspace). On disks without a local root,
such as S3, PHP skips the outside search. In TypeScript and Python, a search
that stopped at its file limit is reported in sweepComplete /
sweep_complete and does not pass.
With Harness#
A Harness Session has a key(), so it can be passed directly as the owner:
each session gets its own workspace, and a resumed session finds its files
again. Harness is not required — any stable string works.
A workspace is not deleted when its session ends. Call clear() when your
application no longer needs the files.
Source: Particle-Academy/prism-workspace.