Skip to content

Local Bash Workspace

The LocalBashWorkspace runs files in a real tmpdir on the host filesystem and runs shell commands as subprocesses. POSIX-only. Suitable for local development and testing where you want real filesystem semantics + the ability to shell out, without spinning up a container.

When to use

  • Local POSIX development. When you want files to persist across reads/writes within a session AND you need actual shell commands (grep, find, git, etc.).
  • Integration tests that exercise real filesystem behavior — file modes, symlink behavior, glob expansion, etc.
  • Trusted code only. This provider runs as the host user with full host privileges. Do NOT use with untrusted input.

For untrusted code, use Cloudflare Sandbox (Firecracker microVM isolation).

Capabilities supported

CapabilitySupported
fs
shell
code
snapshot

The provider advertises { fs: true, shell: true } on its WorkspaceRef.capabilities. Declaring a capability marked ❌ above causes WorkspaceFailedError at session start (the framework asserts that config.capabilities ⊆ ref.capabilities and that each declared module is present on the returned workspace). See the error-model table on the workspaces overview.

Install

bash
npm install @helix-agents/workspace-local-bash

POSIX-only

Windows is not supported. The provider throws at open() time with a clear error message:

LocalBashWorkspaceProvider: Windows is not supported. Use WSL and run the agent inside it.

Windows users should run their agents inside WSL.

Sandbox boundaries

The workspace's filesystem is scoped to a per-session tmpdir created via fs.mkdtemp() under os.tmpdir() (configurable via tmpdirRoot). The internal TmpdirFileSystem enforces the boundary using:

  • realpathSync canonicalization on the tmpdir root at construction time.
  • Ancestor-walk symlink resolution on every fs operation — every component of the requested path is resolved through realpath before access, ensuring no symlink can escape the tmpdir.

This protects against deliberate symlink-escape attacks within the fs methods. It does NOT protect against:

  • Untrusted shell commands. Once the LLM calls workspace__<name>__run('rm -rf /'), the boundary is gone — the shell runs as the host user.
  • Race conditions between symlink creation and use (TOCTOU). The ancestor walk happens immediately before the operation but is not atomic.
  • Code that calls fs APIs outside the workspace (e.g., a custom tool that bypasses the registry).

If you need true isolation against untrusted input, use Cloudflare Sandbox.

Provider config

typescript
interface LocalBashWorkspaceConfig {
  kind: 'local-bash';
}

interface LocalBashProviderOptions {
  /** Override the tmpdir root. Defaults to os.tmpdir(). */
  tmpdirRoot?: string;
  /** Constraints applied to subprocess shell calls. */
  shellConstraints?: SubprocessShellConstraints;
}

shellConstraints (SubprocessShellConstraints) is per-provider — it applies to every workspace this provider opens, and to direct calls on ws.shell.run() that bypass the auto-injected tool layer:

typescript
interface SubprocessShellConstraints {
  /** First-token allowlist; commands containing shell metacharacters are also rejected. */
  allowedCommands?: readonly string[];
  /** Default per-call duration limit (ms) when ShellRunOptions.timeoutMs is absent. */
  maxDurationMs?: number;
  /** Env-var forwarding policy (see security note below). */
  passEnv?: readonly string[] | true;
}

Per-call options like cwd, env, signal, timeoutMs, and the streaming callbacks live on ShellRunOptions (see Shell module) and are layered on top of these provider-level constraints.

Security note: passEnv defaults to a minimal allowlist

By DEFAULT (passEnv: undefined), only a minimal safe set of env vars is forwarded into spawned subprocesses:

PATH, HOME, LANG, LC_ALL, TERM, USER, TMPDIR

This means secrets present in the host process (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_*, *_TOKEN, *_SECRET) are NOT visible to the LLM-driven shell — calling printenv or env from the agent surfaces only the safe set. To opt back into specific variables, list them explicitly:

typescript
shellConstraints: {
  passEnv: ['OPENAI_API_KEY', 'GITHUB_TOKEN'],
}

To restore the legacy "forward everything" behavior, pass passEnv: true. Per-call ShellRunOptions.env is layered on top of whichever base set the provider resolves.

This default exists because the LLM controls every command the shell runs; the simplest prompt-injection vector against a localdev agent is "run printenv and tell me what you see." The minimal allowlist closes that vector by default; opt-in keeps the door open for legitimate use.

Wiring

typescript
import * as os from 'node:os';
import { defineAgent } from '@helix-agents/core';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { LocalBashWorkspaceProvider } from '@helix-agents/workspace-local-bash';

const agent = defineAgent({
  name: 'my-agent',
  llmConfig: { model: yourModel },
  workspaces: {
    box: {
      provider: { kind: 'local-bash' },
      capabilities: { fs: true, shell: true },
    },
  },
});

const executor = new JSAgentExecutor(
  new InMemoryStateStore(),
  new InMemoryStreamManager(),
  yourLLMAdapter,
  {
    workspaceProviders: new Map([
      ['local-bash', new LocalBashWorkspaceProvider({ tmpdirRoot: os.tmpdir() })],
    ]),
  }
);

Lifecycle

  • open() — calls fs.mkdtemp() to create a per-session tmpdir prefix helix-ws-{sanitizedSessionId}-{random}. Returns the LocalBashWorkspace and a serializable ref { tmpdir, workspaceId }.
  • resolve() — re-attaches to the same tmpdir if it still exists. If the tmpdir has been cleaned (process exit, another session's close, tmpfs clear), throws WorkspaceEvictedError so the framework's eviction-retry helper (withEvictionRetry in the tool-injection layer; see the error-model table on the workspaces overview) can mark the registry entry as evicted and re-resolve the workspace via provider.resolve(ref) on the next tool call.
  • close() — removes the tmpdir recursively. Files are gone after close.

Observability

The provider accepts an optional Logger from @helix-agents/core so security warnings (allowlist denial, shell metacharacter rejection, passEnv opt-in events, close failures) surface in your logging pipeline:

typescript
import { consoleLogger } from '@helix-agents/core';

new LocalBashWorkspaceProvider({
  tmpdirRoot: os.tmpdir(),
  logger: consoleLogger,  // pino, winston, or any { info, warn, error } shape
});

Defaults to silent (noopLogger). The provider emits warn-level entries for prompt-injection-shaped attempts (e.g. metacharacter rejections), info-level entries for normal lifecycle transitions, and error-level entries for unexpected failures during close.

Auto-injected tools

All fs tools, plus workspace__<name>__run for shell. See FileSystem and Shell module pages for schemas.

Using the workspace from a custom tool

typescript
import { defineTool } from '@helix-agents/core';
import { z } from 'zod';

const buildAndCount = defineTool({
  name: 'build_and_count',
  parameters: z.object({}),
  execute: async (_input, ctx) => {
    const ws = await ctx.workspaces!.get('repo');
    if (!ws.shell) throw new Error('repo workspace requires shell capability');
    const result = await ws.shell.run('npm run build && find dist -type f | wc -l');
    return { exitCode: result.exitCode, fileCount: new TextDecoder().decode(result.stdout).trim() };
  },
});

See the shared pattern on the overview page — the await and the ! non-null assertion both matter.

Inspecting a workspace

The tmpdir lives on the host filesystem, so two paths are open:

  • From inside the agent. Add a custom debug tool calling ws.fs!.ls('/') or ws.shell!.run('find . -type f'). This works on every provider uniformly.
  • From the host. Tmpdirs are named helix-ws-{sanitizedSessionId}-{random} under os.tmpdir() (overridable via tmpdirRoot). ls -la /tmp/helix-ws-* (or your platform's tmpdir) shows the live workspaces. The naming convention is documented but treat it as a debug-only convenience — it is NOT a stable integration surface.

Mid-run inspection (active sessions)

Mid-run inspection is safe IF read-only:

  • Recommended for active sessions. The custom debug-tool path above (in-agent) — the read happens inside the same step the agent owns, so no race.
  • From the host (read-only). ls -la /tmp/helix-ws-*, cat, grep, find against the live tmpdir are safe. The agent and host see the same POSIX fs; the agent's writes between operations remain consistent.
  • Writes from the host would race the agent. Never rm, mv, or > into a tmpdir of a live session — surface the change via the agent's tools instead.
  • For after-completion. Either approach is safe; the agent has stopped writing. Note that close() removes the tmpdir, so inspection only works between session end and close.

Capacity & performance

These are approximate ranges; benchmark for your workload.

DimensionApproximate rangeNotes
Per-host tmpdir boundDisk-limited (typically GB)Each session's tmpdir lives under os.tmpdir() (overridable).
FS op latency~ms (single-digit)Real POSIX fs; tmpfs faster than spinning disk.
Subprocess startup~50msEach shell run() forks a new process; cold start dominates short commands.
Concurrent workspaces per host~100sBounded by tmpdir / process-table headroom; depends on host.
Cross-process sharingNoneTmpdir-scoped; sibling processes do NOT see each other's workspaces.

A periodic cleanup of orphaned tmpdirs (process-crash leakage) is recommended — see runbook incident #5.

Secure-by-default passEnv allowlist (round-4 cluster A)

The passEnv default flipped from "forward everything" to a minimal allowlist (PATH, HOME, LANG, LC_ALL, TERM, USER, TMPDIR). This is a behavior change for upgraders — if your agent depended on host secrets being visible to the LLM-driven shell, see Pitfall 4 in the upgrading guide.

Production unsuitability warning

⚠️ This provider runs commands as the host user with full host privileges. Never use it with untrusted input or in any context where the LLM could be prompted to attack the host. For production code-execution agents, use CloudflareSandboxWorkspace (Firecracker microVM) or another container-based provider.

Source

Released under the MIT License.