Skip to content

Workspaces Security

This page documents the security threat model for the workspaces feature (the four built-in providers, the registry, auto-injected workspace tools, and the modules they expose). It is not a repo-wide security policy — for vulnerability reporting against the framework as a whole, see SECURITY.md at the repo root.

It is intended for operators integrating Helix Agents workspaces into a production deployment — not for end users of agents built on the framework. If you operate a Helix Agents system that processes adversarial input or LLM prompt-injection vectors, read this document carefully and apply the recommendations for your deployment shape.


Deployment shape: trusted-host sandbox assumption

The framework assumes the operator deploys it inside a host-level sandbox for any deployment that processes untrusted input. Common host sandbox examples (operator picks the platform):

  • Docker / Podman containers.
  • gVisor (runsc) — Linux kernel re-implementation in userspace; strong syscall isolation.
  • Firecracker / microVM — used by Modal, Vercel Sandbox, E2B, AWS Fargate, Fly.io.
  • Kubernetes Pod with a hardened pod security configuration.

Under this assumption, the framework focuses its defenses on PROCESS-LEVEL correctness (one orchestrator process serves N sessions; sessions must not corrupt each other), LLM-LEVEL safety (prompt injection, context overflow, tool-result handling), and OPERATIONAL correctness (eviction recovery, audit logs, hooks/metrics). It does NOT try to duplicate the HOST-LEVEL resource controls (CPU exhaustion, OOM, disk fill, network DoS) that the external sandbox already enforces via cgroups, memory limits, disk quotas, and network policies.

The cloudflare-sandbox provider already operates under this model — the Cloudflare Container is the host sandbox; the framework's in-sandbox defenses are defense-in-depth. The clarification just makes the model explicit and extends it to local-bash.

Deployment shapes

ShapeProviderHost sandboxNotes
Local devlocal-bash directly on operator's machineNone neededTrusted code only.
Production with per-session host sandboxlocal-bash running INSIDE a host sandbox (Docker / gVisor / Firecracker / K8s pod / Modal / Vercel Sandbox / E2B)One sandbox per session (or per-isolation-unit chosen by the operator)The sandbox boundary is the session's isolation boundary.
Production on a dedicated hostlocal-bash directly on a dedicated host (one customer per VM, one workload per Fargate task, etc.)Host == isolation boundarySame hardening as local dev.

How sessions map to tenants/users/customers/billing-units is the operator's call — the framework provides session-scoped primitives. Isolation between sessions is the framework's responsibility; isolation between deployments (one process per tenant, partitioning state stores, log-sink routing, network egress filtering, etc.) is the operator's domain.

Defense classification

Defenses split cleanly into four classes by who is responsible:

ClassWho handles itExamples
HOST-LEVELExternal host sandboxCPU/memory/disk quotas, network egress firewall, kernel-namespace isolation, syscall filtering (seccomp), file-descriptor limits, process-count caps, side-channel mitigations (per-isolation-unit process).
PROCESS-LEVELFramework (intra-process correctness)Per-session capability scoping, snapshot ownership tracking, swap-ref drain, capability invariant assertion, branch-from-checkpoint nulling, session-scoped registry isolation, per-call wall-clock budgets (V8 isolate fairness across sessions).
LLM-LEVELFramework (LLM safety)Tool-result size caps + boundary tags, command allowlists, metacharacter / glob rejection, ReDoS heuristic (V8) + RE2 backend (linear-time), env-var denylist (defense-in-depth alongside sandbox-disabled setuid).
OPERATOROperator (deployment plumbing)Audit log sink immutability, secret management, OS-level firewall rules, choice of model, model-side prompt-injection filtering, human-in-the-loop approval gates, mapping sessions to tenants/customers, partitioning state stores.

The HOST-LEVEL row is what the external sandbox already provides. The framework intentionally does not duplicate it.

Minimum sandbox primitives

The host sandbox the operator picks should provide at minimum, expressed as platform-agnostic concepts:

  • PID + network + mount + UTS namespaces.
  • Non-root user.
  • Read-only base filesystem + writable tmpdir.
  • PR_SET_NO_NEW_PRIVS.
  • CPU + memory quota.
  • No setuid binaries in the base image.
  • Network egress policy appropriate to the workload (no egress by default; explicit allowlist if outbound calls are required).

Operators map these to their chosen platform — Docker / Podman flags, Kubernetes pod security configuration, Modal / Vercel Sandbox / E2B / Firecracker / gVisor / Fargate options, etc. The framework does not endorse one platform over another; it relies on the host sandbox to enforce these primitives at the kernel boundary.

Operators can also run a single Helix Agents process WITHOUT a host sandbox on a dedicated host. That is a valid shape; the framework's defenses (allowlist, metachar rejection, snapshot ownership tracking, etc.) are the protection in that case. Running untrusted-input agents on a bare shared host is the unsupported shape.


Threat model — IN scope

The workspaces feature defends against the following attack surfaces. Each is enforced in code and (where relevant) backed by automated tests. Round numbers reference the security-remediation rounds documented in the changelog.

Path + filesystem isolation

  1. Path traversal via ../. The TmpdirFileSystem (local-bash) and CloudflareSandboxFileSystem (sandbox) reject any requested path whose path.relative() against the workspace root walks above the root. Verified at packages/workspace-local-bash/src/filesystem.ts (resolveScoped).
  2. Symlink leaf escape via realpath. Existing leaves are resolved via fs.realpath; the resolved path is checked against the workspace root and rejected on escape.
  3. Symlink ancestor escape via ancestor walk. When the leaf doesn't exist (e.g. writeFile('/new-file')), the framework walks up the ancestor chain and realpath-checks each existing ancestor. A symlinked-ancestor escape is detected even when several intermediate levels are missing.
  4. Leaf-symlink-swap TOCTOU race. As of round 6 (S3), readFile / writeFile / stat use O_NOFOLLOW on the leaf so a malicious parallel process cannot swap the leaf into a symlink between realpath and open(2). The leaf race window — present in any userspace realpath + open two-step — is closed.
  5. WorkspaceDir scoping in sandbox FS. rm({path: '/'}) and similar commands are rejected at the FS layer in the Cloudflare sandbox provider so a misbehaving tool cannot wipe the container's root filesystem.

Shell + command isolation

  1. Shell metacharacter chaining rejection. ;, |, &&, ||, `, $(...), >, <, etc. rejected at the auto-injected run tool's Zod boundary AND at the SubprocessShell.run() / CloudflareSandboxShell.run() boundary (defense-in-depth for direct ws.shell.run() callers from custom tools).
  2. Glob/brace/wildcard expansion smuggling. {, }, *, ?, [, ], ~ rejected by default — cat /etc/{passwd,hostname} is blocked even when cat is in the allowlist. Operators opt in via capabilities.shell.glob: true only when they have audited that the agent's allowed commands cannot be smuggled into filesystem enumeration.
  3. Privilege-escalation env var denylist. LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, LD_DEBUG, NODE_OPTIONS, RUBYOPT, PYTHONSTARTUP, PYTHONPATH, PERL5OPT, BASH_ENV, etc. rejected at both the schema layer and the runtime boundary. Agents cannot use the env field to inject linker payloads (e.g. via a planted .so).
  4. Command allowlist secure-by-default. As of round 4 (A1), an empty/undefined allowedCommands denies ALL commands. Operators must explicitly opt in by listing permitted first-tokens. The historical "boolean true means allow everything" default is gone.
  5. Cwd-escape rejection. Per-call cwd overrides are canonicalized via realpath and rejected if they resolve outside the workspace tmpdir.

Pattern + input safety

  1. ReDoS heuristic + wall-clock backstop. Grep patterns with nested quantifiers, alternations with shared prefixes, or long ?-quantified runs are rejected at the Zod boundary by assertSafeGrepPattern. Remaining pathological execution time is bounded by a per-call wall-clock budget enforced between files. The heuristic is a "catch the common shapes" detector, not a proof — the wall-clock budget is the real backstop. Round 6 (S2) introduced an opt-in RE2 backend that eliminates the entire ReDoS class for adversarial-input deployments.
  2. Tool-result size caps + boundary tags. Returned stdout/stderr/file-contents are wrapped in <workspace_tool_result untrusted="true"> boundary tags AND capped at configurable byte limits (defaults in core/workspace/constants.ts). The boundary tags signal the trust boundary to the LLM (paired with a system-prompt fragment); the byte caps prevent context-window exhaustion from an adversarial file.

Cross-session isolation

  1. Cross-session snapshot ownership. Snapshot refs carry an originSandboxId. Round 5 (A5) requires allowCrossSession: true to restore a snapshot whose origin sandbox differs from the current session. Round 7 extended the same opt-in to the new Snapshotter.list() and Snapshotter.delete() methods so an adversarial session cannot enumerate or prune another session's snapshots. Closes a cross-session data-mixing class on shared R2 namespaces.
  2. Sandbox config.id cross-session guard. Round 4 (A6) rejects an explicit config.id that differs from session.sessionId unless shareAcrossSessions: true is opted in. The opt-in carries a clear log warn so operators can audit every cross-session reuse.
  3. Branch-from-checkpoint nulls workspaceRefs. A branched session does not inherit the parent's workspace refs; each branched run opens fresh refs. Closes a class of cross-branch container reuse.

Integrity + tamper resistance

  1. Tamper-resistant ref payloads via Zod. Persisted refs (snapshot, workspace) are validated via safeParse on every reload. The schema is the single source of truth; a mutated payload (manual edit, tampering) is rejected with a generic message that does NOT echo the tampered value back into logs.
  2. Ref schema versioning. Round 4 (D4) added schemaVersion to every ref. Providers enforce [N-1, N, undefined] acceptance so a forward-only schema change can be rolled out across a fleet without bricking sessions in flight.
  3. Capability invariant assertion. Round 2 added an assertCapabilitiesSatisfied check at session start: a workspace declared with { fs: true } whose provider returns a workspace without an fs module fails fast with a clear error. Closes a class of "tool exposed but module missing" UX bugs.
  4. swapRef in-flight drain. Round 5 (A4) ensures a snapshot/restore swap waits for in-flight tool ops to drain before closing the old workspace handle. Closes a write-loss race where a parallel write_file could land on the old workspace mid-close.

Surface hygiene

  1. Tool-name collision detection. workspace__ and companion__ prefixes are reserved. Custom tools using these prefixes fail-fast at agent-construction time with a clear error.
  2. Audit logging for security-relevant rejections. Every metachar / glob / allowlist denial, env denial, leaf-symlink rejection, capability mismatch, snapshot cross-session attempt, eviction-retry exhaustion, etc. emits a structured warn through the configured Logger. Round 5 (B5) added rate-limited dedup (first occurrence always emits; subsequent within a 60s window emit a periodic rollup count) so an adversarial loop cannot saturate the audit log. Round 6 (S4) added sessionId and userId to every audit payload so SOC 2 CC7.2 "WHO" requirements are satisfied. Per-session dedup keying ensures a hostile session cannot suppress the FIRST occurrence of the same event in another session's stream.
  3. Snapshot pruning surface (round 7). Pre-round-7 an LLM could call snapshot() 10×/sec; the resulting R2 archives accumulated forever with no framework-side mitigation (operators relied on R2 lifecycle policies as the only floor). Round 7 added Snapshotter.list() + Snapshotter.delete() and the corresponding auto-injected tools (workspace__<name>__list_snapshots, ..._delete_snapshot); agents and operators can now bound R2 storage reactively. The list_snapshots tool clamps the LLM-supplied limit to a configurable ceiling (capabilities.snapshot.maxListResults, default 100) so the listing tool itself doesn't become a sibling cost-amplification vector.

Threat model — OUT of scope

The following are explicit operator-responsibility items. The workspaces feature provides defense-in-depth where possible but cannot, in TypeScript on a stock POSIX or Worker host, achieve the isolation a microVM or kernel-namespace sandbox provides.

TOCTOU ancestor-chain race (local-bash)

The LEAF race is closed via O_NOFOLLOW (round 6, S3). The ANCESTOR race remains: between realpath(parent) and the leaf operation, a hostile parallel process can swap an ancestor directory into a symlink. Closing this would require Linux 5.6+ openat2(2) with RESOLVE_BENEATH, which Node does not expose.

Operator mitigation: when accepting untrusted input on local-bash, do NOT allowlist ln, mv, cp -P, cpio, tar -P, mkdir, mkfifo, or ar. Or switch to @cloudflare/sandbox for real kernel-namespace isolation.

Network exfiltration

LocalBashWorkspace provides no network namespace isolation. If the operator allowlists curl, wget, node, python, etc., the LLM can reach arbitrary external networks.

Operator mitigation: don't allowlist network-capable commands; or run agents under OS-level firewall rules / iptables egress restrictions; or switch to @cloudflare/sandbox (sandbox containers have configurable egress controls via Cloudflare DNS / firewall product).

Host-level resource exhaustion (CPU / memory / disk / network DoS)

Host-level resource exhaustion is the external host sandbox's responsibility in untrusted-input deployments — see Deployment shape: trusted-host sandbox assumption above. The framework's maxDurationMs, tool-result byte caps, and per-call wall-clock budgets are PROCESS-LEVEL fairness controls (one V8 isolate must remain responsive for the other sessions running in the same orchestrator process), NOT host-level resource limits. They prevent one session from starving N sibling sessions inside the same Node process; they do not bound total host CPU, memory, or disk usage.

A sort < /dev/urandom-like resource-exhaustion attack — slipped past the metachar guard via a future allowlisted helper script — would consume the SANDBOX's quota, not the host's. The kernel's CPU/memory limits, the container's tmpfs disk quota, the network policy's egress firewall, and the file-descriptor / process-count caps in the host sandbox bound the impact to the sandbox boundary.

Operator mitigation:

  • For untrusted-input deployments: run inside a host sandbox with the Minimum sandbox primitives above. CPU/memory quotas, network policies, and file-descriptor / process-count caps live at the sandbox boundary.
  • For production WITHOUT a host sandbox (dedicated host, one workload per VM): apply OS-level resource limits (systemd slice with CPUQuota= / MemoryMax=, prlimit for fd/proc caps, iptables egress rules). The framework provides no defense for this shape beyond its PROCESS-LEVEL and LLM-LEVEL controls.

Side-channel attacks

The framework does not defend against timing, cache, Spectre, or other microarchitectural side-channel attacks. The local-bash provider runs agents in the same Node process; cross-session secret extraction via timing channels is theoretically possible.

Operator mitigation: run different isolation units in different Node processes / containers for any deployment with a side-channel threat model.

Container / microVM escape

For the Cloudflare sandbox provider, kernel-namespace isolation is delegated to @cloudflare/sandbox. The framework relies on the upstream provider's container security guarantees. Sandbox escape (a sandboxed process breaking out of the container into the host kernel) is a Cloudflare-side concern and tracked in their security disclosures.

local-bash safety on a bare shared host

LocalBashWorkspace running on a bare host (no host sandbox) is trusted-input by design. The session's tmpdir is scoped, but any subprocess the agent spawns runs as the host user and can read the WHOLE host filesystem (subject to file modes).

This restriction is about the deployment shape, not the provider. Inside a host sandbox (Docker / gVisor / Firecracker / K8s pod / Modal / Vercel Sandbox / E2B), local-bash is safe for untrusted input — the sandbox boundary is the isolation boundary, and the orchestrator process inside that sandbox serves only the workloads inside it. See Deployment shape: trusted-host sandbox assumption above for the recommended shapes.

For untrusted-input production deployments, the standard pattern is one container per session (or per-isolation-unit chosen by the operator) via Modal / Vercel Sandbox / E2B / AWS Fargate / Kubernetes / etc. local-bash runs INSIDE the per-session container; each container has its own quotas + namespaces from the sandbox layer. cloudflare-sandbox is the alternative when running on Cloudflare Workers.

LLM prompt-injection causing adversarial use of allowed surface

The framework provides defense-in-depth (boundary tags around untrusted content, command allowlists, glob denial) but cannot prevent a model from being convinced to invoke its allowed surface adversarially. If the LLM is allowlisted for git and is prompt-injected via a fetched README to push to an attacker-controlled remote, the framework's boundary tags reduce success rate but do not eliminate it.

Operator mitigation: keep allowlists narrow; use content filtering / model selection appropriate to the deployment; layer human-in-the-loop approval for high-stakes operations (e.g. git push, financial transactions).

Audit-log immutability

The framework emits structured audit warns through the configured Logger. Whether the operator's logging stack provides immutable, append-only delivery is the operator's responsibility.

Operator mitigation: ship audit logs to an immutable sink: AWS CloudWatch Logs with object-lock-equivalent retention, GCP Audit Logs, an immutable S3 bucket with Object Lock, or a SOC 2-compliant log aggregator (Datadog, Splunk in compliance mode).

Secrets in tool-result strings

Errors thrown from TmpdirFileSystem and SubprocessShell include user-supplied paths verbatim because that's what makes the messages useful to the LLM agent — it can reason about WHY the call failed and try a different path. This is a deliberate trade-off favoring agent debuggability.

Operator mitigation: isolation MUST happen at the session-state and stream-chunk layer of the executor — NOT by stripping paths from these error messages (doing both would defeat the agent's ability to debug its own failures).

Read-only filesystem mounts

Tmpdir contents and sandbox containers are writable by design. The framework does not provide a read-only mount mode.


Provider security comparison

ProviderIsolationDefensesSuitable for
in-memory (@helix-agents/workspace-memory)None — pure JS MapTmpdir-equivalent path scoping; no shell/codeDev/test ONLY. Data lost on close.
local-bash (@helix-agents/workspace-local-bash)Per-session tmpdir; no process isolationSymlink + scope + allowlist + metachar + glob + env-deny + leaf-O_NOFOLLOW hardeningTrusted-input local development. Production on a dedicated host (one workload per VM). Production INSIDE a host sandbox (Docker / gVisor / Firecracker / K8s pod / Modal / Vercel Sandbox / E2B) — the sandbox boundary is the isolation boundary. NOT suitable for untrusted input on a bare shared host.
cloudflare-filestore (@helix-agents/runtime-cloudflare)Per-DO SQLite + R2No shell/code; FS-only with ranged readsProduction note storage for durable agent memory. No code execution surface.
cloudflare-sandbox (@helix-agents/runtime-cloudflare)Linux container with kernel-namespace isolationAllowlist + metachar + glob + workspaceDir scoping + audit loggingUntrusted-input production. The recommended provider for any deployment processing adversarial input on Cloudflare.
temporal runtimeWorkspaces unsupportedFail-fast at run-start with assertRuntimeSupportsWorkspacesN/A
cloudflare workflows runtimeWorkspaces unsupportedSame fail-fastN/A

Local development

  • Provider: local-bash with a narrow allowlist (['ls', 'cat', 'grep', 'git', 'npm'] or similar).
  • passEnv: leave default (minimal allowlist) — LLM cannot read host secrets via printenv.
  • glob: false (default) — opt in only after auditing.
  • Audit log sink: structured JSON to stdout / a local file; review periodically for prompt-injection patterns.

Production on a dedicated host (one workload per VM)

  • Provider: local-bash (acceptable when the host is dedicated to a single workload) OR cloudflare-sandbox (preferred for ease of operation).
  • Allowlist: narrowest possible — every additional command is audited surface.
  • passEnv: ['EXPLICIT_VAR_1', 'EXPLICIT_VAR_2'] — explicit allowlist, no host secrets.
  • Audit log sink: ship to a structured-log destination (Datadog, Loki, CloudWatch). Set up alerting on rate of audit-warn events as a prompt-injection indicator.
  • Apply OS-level resource limits (systemd slice with CPUQuota= / MemoryMax=, prlimit for fd/proc caps, container CPU/memory quotas). On a dedicated host, this layer is the operator's responsibility — there is no host sandbox to inherit them from.

Production with a host sandbox (Modal / Vercel Sandbox / E2B / Fargate / Kubernetes / Docker / gVisor / Firecracker)

  • Provider: local-bash is fine when running INSIDE a host sandbox; the sandbox boundary is the isolation boundary. Or use cloudflare-sandbox if deploying on Cloudflare Workers.
  • Host sandbox: one sandbox per session (or per-isolation-unit chosen by the operator) with the Minimum sandbox primitives above.
  • Allowlist: configurable via the agent config; defaults to narrowest set.
  • Audit log sink: ship to a compliance-grade immutable sink.
  • ReDoS hardening: install re2-wasm and configure WorkspaceRegistryDeps.regexEngine = await detectRegexEngine() to switch to RE2 — this is LLM-LEVEL (sandbox doesn't help with V8 isolate fairness across the orchestrator's own sessions).
  • Resource limits: handled by the host sandbox (cgroup CPU/memory, tmpfs disk quota, network policy, fd/proc caps). The framework's maxConcurrentOpens / maxGlobalConcurrentOpens are PROCESS-LEVEL (intra-process fairness across N sessions in one orchestrator) — usually unnecessary when each session gets its own sandbox.

Production on Cloudflare Workers

  • Provider: cloudflare-sandbox — the Cloudflare Container is the per-session host sandbox.
  • One sandbox per session by default (do NOT enable shareAcrossSessions unless the cross-session data-sharing implications are explicitly accepted).
  • Audit log sink: ship to a compliance-grade immutable sink.
  • ReDoS hardening: same as above (LLM-LEVEL; install re2-wasm).
  • maxGlobalConcurrentOpens is mainly relevant if multiple agent DOs share a single Sandbox DO binding. Each binding's max_instances is the upstream limit.

Compliance-driven deployment (SOC 2 / ISO 27001)

  • All of the host-sandbox posture above PLUS:
  • Audit log sink: immutable, append-only (S3 Object Lock, CloudWatch Immutable, GCP Audit Logs, or equivalent).
  • All security warns include sessionId + userId (round 6 S4) — verify these flow through to your log sink.
  • Document the IN-scope and OUT-of-scope list above as part of your control evidence. The HOST-LEVEL gaps (resource exhaustion, side-channel, network egress, syscall filtering) are explicitly delegated to your host sandbox layer — document the sandbox configuration as the compensating control.
  • Capability allowlists per agent reviewed quarterly; new commands require a security review.
  • Regular internal pen-testing against the agent's exposed tool surface — audit warns observed during tests are evidence the defenses are actually engaging.

Audit log content + sinks

The framework emits structured warn-level audit events via the configured Logger. Round 6 (S4) ensures every payload carries sessionId, userId (when set on the session), and runId (when bound at registry construction) alongside the existing workspaceId so log queries can answer SOC 2 CC7.2's "WHO did this" requirement.

Audit events emitted

| Event | Source | Trigger | | ------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------- | | local-bash: shell metacharacter rejected | SubprocessShell + auto-injected run tool | Command contains ;, |, &&, `, $(...), etc. | | local-bash: shell glob/brace expansion rejected | same | Command contains {, }, *, ?, [, ], ~ (without opt-in) | | local-bash: command not in allowlist | same | First-token not in allowedCommands | | local-bash: no allowlist configured — command rejected | same | Empty/undefined allowlist | | local-bash: privilege-escalating env var rejected | same | LD_PRELOAD/NODE_OPTIONS/etc. submitted | | local-bash: cwd-escape rejected | SubprocessShell.resolveCwd | Per-call cwd resolves outside tmpdir | | local-bash: path traversal rejected | TmpdirFileSystem.resolveScoped | Lexical ../ escape | | local-bash: symlink-escape rejected | TmpdirFileSystem.resolveScopedSafe | Leaf symlink resolves outside tmpdir | | local-bash: symlink-escape rejected (ancestor) | same | Ancestor symlink resolves outside tmpdir | | local-bash: leaf is a symlink — rejected (O_NOFOLLOW) | TmpdirFileSystem.readFile / writeFile / stat | Leaf is a symlink under default-deny (round 6 S3) | | local-bash: grep aborted (deadline or signal) | TmpdirFileSystem.grep | Wall-clock budget exhausted or signal aborted | | cloudflare-sandbox: shell metacharacter rejected | CloudflareSandboxShell | Same as local-bash equivalent | | cloudflare-sandbox: shell glob/brace expansion rejected | same | Same | | cloudflare-sandbox: command not in allowlist | same | Same | | cloudflare-sandbox: no allowlist configured — command rejected | same | Same | | cloudflare-sandbox: privilege-escalating env var rejected | same | Same | | workspace tool: shell metacharacter rejected | tool-injection.ts | Auto-injected run tool's pre-call check | | workspace tool: shell glob/brace expansion rejected | same | Same | | workspace tool: command not in allowlist | same | Same | | workspace tool: no allowlist configured — command rejected | same | Same | | workspace tool: edit_file oldText empty | same | Empty oldText pattern (LLM mis-use indicator) | | workspace tool: grep pattern flagged by heuristic but accepted (linear-time engine) | same | RE2 engine + heuristic match (round 6 S2) | | workspace tool: eviction retry exhausted | registry | WorkspaceEvictedError after exhausted retries |

Payload schema

Every payload contains the following fields (some may be absent for events fired before session context exists):

  • workspaceName: agent-declared name (when emitted from tool-injection)
  • workspaceId: provider-assigned ID (when emitted from FS/shell modules)
  • providerId: e.g. 'local-bash', 'cloudflare-sandbox'
  • sessionId: current session (round 6 S4)
  • userId: session's user identifier (round 6 S4)
  • runId: current run (when bound at registry construction)
  • traceId, spanId: OpenTelemetry context (when wired)
  • Event-specific: firstToken, matchedMetachar, matchedGlobChar, envKey, path, resolved, snippet (scrubbed)
  • snippet is bounded to 80 chars and high-confidence secrets are scrubbed via the round 4 (D5) scrubLikelySecrets helper before logging.

Rate-limit semantics

Round 5 (B5) introduced RateLimitedLogger.warnRateLimited for HIGH-CARDINALITY security warns:

  • First occurrence: ALWAYS emits.
  • Subsequent identical events within securityWindowMs (60s default): suppressed but counted.
  • Rollup: every securityRollupMs (5s default), if there are suppressed events, a [rate-limited] suppressed Nx of: <message> line emits at warn level.
  • Per-session keying (round 6 S4): the dedup key incorporates the bound sessionId so an adversarial loop in session A cannot suppress the FIRST occurrence of the same event in session B.
  • Production: any structured-log destination — Datadog, Loki, CloudWatch, GCP Cloud Logging, Honeycomb. Filter on level: warn AND a workspaceName/workspaceId field for the security feed.
  • Compliance: an immutable, append-only store: AWS S3 with Object Lock + CloudTrail, GCP Cloud Logging with retention lock, Splunk in compliance mode, or a SOC 2-attested log aggregator. Audit event payloads carry sessionId/userId so per-session retention policies can be applied.

Regex engine — opt-in RE2

Round 6 (S2) introduced an optional RE2 backend. If the operator processes adversarial input, install re2-wasm (or any RE2 binding via createRe2Engine(yourModule)) and wire:

typescript
import { detectRegexEngine, DefaultWorkspaceRegistry } from '@helix-agents/core';

const regexEngine = await detectRegexEngine();
const registry = new DefaultWorkspaceRegistry({
  // ...other deps...
  regexEngine,
});

When a linear-time engine is wired:

  • Grep patterns with ReDoS-prone shapes ((a+)+, (?:a|aa)*b, etc.) are ACCEPTED (no longer rejected at the schema layer).
  • The structural heuristic still RUNS and emits a workspace tool: grep pattern flagged by heuristic but accepted (linear-time engine) warn so operators can spot prompt-injection patterns even though they can't burn CPU.
  • The wall-clock backstop in grep remains active as an informational safety net for extreme cases (huge file, slow IO). Under RE2's linear-time guarantee it will not fire under normal use; the registry surfaces an info log at construction time to confirm the linear-time engine took effect.

If re2-wasm is unavailable in your runtime (e.g. some Cloudflare Workers configurations), use createRe2Engine(nodeRe2Module) to bring your own binding. The interface is documented in packages/core/src/workspace/utils/regex-engine.ts.


Known gaps + roadmap

Honest list of things the workspaces feature cannot defend against today:

GapWhyRoadmap
TOCTOU ancestor-chain race (local-bash)Node lacks openat2(2) with RESOLVE_BENEATH bindingTrack upstream Node when binding lands; until then operator-mitigation only. Defense-in-depth under host sandbox: escape only reaches the sandbox view.
No seccomp / syscall filtering on local-bashTS framework limitation — HOST-LEVELExternal host sandbox provides this (e.g. gVisor runsc, container seccomp profiles).
No native namespace isolation on local-bashCross-platform constraint — HOST-LEVELExternal host sandbox provides PID/network/mount/UTS namespaces by default.
No native cgroup / ulimit enforcement on local-bashHOST-LEVELExternal host sandbox provides cgroup CPU/memory limits. Bare host: systemd slice, container caps.
Capability extension point hard-codedRound 3 cluster C — known limitation, documented in registry sourceFuture major-version refactor when the surface is stable
No tamper-evident audit chainOperator's logging stackRecommendation: ship to immutable sink
Resource-exhaustion attacks (no fd cap, no proc cap)HOST-LEVELExternal host sandbox provides fd/process caps and resource limits.
Side-channel attacks (timing, cache, Spectre)Architectural — process-levelRun isolation units in different processes (one orchestrator process per isolation unit inside its own sandbox).
Container/microVM escapeDelegated to upstream @cloudflare/sandbox or operator-chosen sandboxTracked as upstream provider / operator concern.

This document is updated each remediation round. The source of truth for which defenses are wired in code is the __tests__/ directory across packages/workspace-local-bash, packages/runtime-cloudflare, packages/workspace-memory, and packages/core/src/workspace. When in doubt, grep for // SECURITY / // S\d / // round- markers in the source.

Released under the MIT License.