Workspace Management
Unified workspace resolution, path validation, and sandbox boundaries.
What This Module Is
The workspace module (soothe.foundation.workspace) resolves, validates, and isolates the directories where Soothe agents operate. It’s the security boundary between agent file operations and the host filesystem. Every tool that reads, writes, or lists files goes through workspace-aware resolution.
The module provides: daemon workspace resolution (ephemeral fallback), client workspace validation, stream-scoped workspace resolution (RFC-103), the FrameworkFilesystem singleton, and a workspace-aware backend wrapper.
Source: packages/soothe/src/soothe/foundation/workspace/ — modules: resolution.py (daemon/client resolution), core_resolution.py (shared precedence), stream_resolution.py (runner stream), runtime_resolution.py (tool execution), loop_workspace.py (loop-scoped), framework_filesystem.py (singleton), normalized_backend.py (workspace-aware backend), virtual_home.py (sandbox mode), context.py (workspace ContextVar), tool_path_resolution.py (virtual_mode decision), migration.py (layout migration)
The Workspace Resolution Hierarchy
Workspaces resolve through a priority chain, depending on context:
Daemon Workspace (Fallback)
resolve_daemon_workspace() is the fallback when no user workspace is resolved. Priority:
SOOTHE_WORKSPACEenvironment variable (absolute override).- TEMP directory:
$TEMP/soothe-daemon-workspace(ephemeral — not persisted across restarts).
This is only for anonymous users without a client_workspace. The daemon workspace is intentionally ephemeral to avoid accumulating files from anonymous sessions.
Client Workspace
Users can specify a client_workspace path. This is validated against INVALID_WORKSPACE_DIRS — a set of system directories (/, /etc, /usr, /bin, /root, etc.) that must never be used as a workspace. The validation raises ValueError if the resolved path matches a system directory.
Loop-Scoped Workspace (RFC-103)
For daemon runs, resolve_persisted_loop_workspace() resolves $SOOTHE_HOME/data/workspaces/<user>/ws_<hash>. The hash is computed from user_id and a scope key (client_workspace_id or loop_id):
- Anonymous users (
user_idis empty/None) → directory segment isanonymous, and the hash uses an empty user string. - Named users → user_id is sanitized (
[^w-.@]+→_) and used in the path and hash.
This creates per-loop isolation: each loop gets its own workspace directory, preventing cross-loop file contamination.
Stream Workspace
resolve_workspace_for_stream(stream_id, config) resolves the workspace for a specific runner stream. This is what the runner calls in its pre-stream phase.
FrameworkFilesystem — The Singleton
FrameworkFilesystem is a singleton (_instance class variable) initialized once with config and policy. It provides the consistent filesystem backend for:
- Tool operations (via middleware)
- Framework operations (reports, checkpoints, manifests)
- CLI operations (final reports, health checks)
Virtual Mode — The Sandbox Switch
The critical design decision: virtual_mode controls whether paths are sandboxed.
virtual_mode=True(default whensecurity.allow_paths_outside_workspaceisFalse) — all paths treated as virtual underroot_dir. A path like/etc/passwdbecomes{root}/etc/passwd. The agent cannot escape the workspace.virtual_mode=False— absolute paths used as-is. A path like/etc/passwdwrites to the real/etc/passwd. Relative paths resolve under root.
This is not a “bug workaround” — it’s the documented sandbox semantics. The default (True) ensures agents operate in a sandbox. Setting allow_paths_outside_workspace=True disables sandboxing, which is appropriate only for trusted local CLI usage.
Workspace-Aware Backend
FrameworkFilesystem.initialize() creates a WorkspaceAwareBackend that reads the active workspace from a ContextVar (RFC-103). This means the workspace can change per-async-task without reinitializing the singleton. The backend reads the ContextVar on each operation, allowing concurrent loops with different workspaces to share the same singleton instance.
Anonymous Workspace Cleanup
cleanup_anonymous_workspaces() runs at daemon shutdown. It removes:
$SOOTHE_HOME/data/workspaces/anonymous/(current layout)- Legacy flat
anon_*directories (pre-migration layout) - Old
$SOOTHE_HOME/workspaces/anonymous/(pre-migration)
This prevents ephemeral anonymous workspaces from accumulating on disk across daemon restarts.
Migration Support
The migration.py module handles workspace layout migrations between Soothe versions. The current layout is $SOOTHE_HOME/data/workspaces/<user>/ws_<hash>, but older versions used different paths. The migration code moves workspaces to the current layout transparently.
Path Validation
Workspace validation checks:
- Path exists and is a directory
- Path is readable (and writable if required)
- Path is not in
INVALID_WORKSPACE_DIRS(system directories) - Path complies with the security policy (no forbidden patterns, appropriate permissions)
Security validation (validate_workspace_security) checks the path against the PolicyProtocol — ensuring the policy allows read/write/execute on that path.
Integration Points
- SootheRunner — calls
resolve_workspace_for_stream()in the pre-stream phase; passes workspace to StrangeLoop viaconfig.configurable. - CoreAgent — receives workspace via
config.configurable.workspace;WorkspaceContextMiddlewareinjects it into tool context. - StrangeLoop — receives workspace as a parameter; uses it for skill sync and
filesystem_virtual_modedetection. - Tools — all filesystem tools operate through
FrameworkFilesystem, which enforces the workspace boundary.
Tool Path Resolution
tool_path_resolution.py provides filesystem_virtual_mode_from_soothe_config() — the function StrangeLoop uses to determine whether paths should be sandboxed. This centralizes the virtual_mode decision so tools and the loop agree on sandboxing semantics.
Minimal Usage
from soothe.foundation.workspace import resolve_daemon_workspace, FrameworkFilesystem
from soothe.config import SootheConfig
config = SootheConfig.from_yaml_file("config.yml")
workspace = resolve_daemon_workspace() # SOOTHE_WORKSPACE or TEMP fallback
FrameworkFilesystem.initialize(config, policy=resolve_policy(config))
# Singleton is now ready; tools use it via middleware
Configuration
security:
allow_paths_outside_workspace: false # true = disable sandbox (CLI only)
filesystem_middleware:
max_file_size_mb: 10 # per-file size cap
The workspace directory itself is resolved at runtime (not from config) — it comes from the client, the environment, or the loop scope.
Gotchas
- The daemon workspace is ephemeral — by default, it’s a TEMP directory. Files written there vanish on daemon restart. For persistence, set
SOOTHE_WORKSPACEor use client workspaces. virtual_modeis a security boundary — settingallow_paths_outside_workspace: truelets agents write anywhere on the host. Only do this for trusted local CLI usage, never for daemon deployments serving multiple users.- The ContextVar is per-async-task —
WorkspaceAwareBackendreads the workspace from a ContextVar, so it’s scoped to the current async task. If you spawn tasks, they inherit the parent’s workspace unless explicitly set. - Anonymous workspaces accumulate — without
cleanup_anonymous_workspaces()at shutdown, anonymous workspace directories persist indefinitely. The daemon calls this at shutdown; if you run the runner outside the daemon, you may need to call it yourself. - System directory validation is a blocklist —
INVALID_WORKSPACE_DIRSis checked by exact string match on the resolved path. Symlinks to system directories are not detected by this check (though the policy layer may catch them).
Related
- Agent Factory — workspace middleware integration
- SootheRunner — stream workspace resolution
- Security Policy — policy enforcement
- RFC-102 — security policy spec
- RFC-103 — thread workspace spec