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
| Shape | Provider | Host sandbox | Notes |
|---|---|---|---|
| Local dev | local-bash directly on operator's machine | None needed | Trusted code only. |
| Production with per-session host sandbox | local-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 host | local-bash directly on a dedicated host (one customer per VM, one workload per Fargate task, etc.) | Host == isolation boundary | Same 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:
| Class | Who handles it | Examples |
|---|---|---|
| HOST-LEVEL | External host sandbox | CPU/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-LEVEL | Framework (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-LEVEL | Framework (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). |
| OPERATOR | Operator (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
- Path traversal via
../. TheTmpdirFileSystem(local-bash) andCloudflareSandboxFileSystem(sandbox) reject any requested path whosepath.relative()against the workspace root walks above the root. Verified atpackages/workspace-local-bash/src/filesystem.ts(resolveScoped). - Symlink leaf escape via
realpath. Existing leaves are resolved viafs.realpath; the resolved path is checked against the workspace root and rejected on escape. - Symlink ancestor escape via ancestor walk. When the leaf doesn't exist (e.g.
writeFile('/new-file')), the framework walks up the ancestor chain andrealpath-checks each existing ancestor. A symlinked-ancestor escape is detected even when several intermediate levels are missing. - Leaf-symlink-swap TOCTOU race. As of round 6 (S3),
readFile/writeFile/statuseO_NOFOLLOWon the leaf so a malicious parallel process cannot swap the leaf into a symlink betweenrealpathandopen(2). The leaf race window — present in any userspacerealpath+opentwo-step — is closed. - 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
- Shell metacharacter chaining rejection.
;,|,&&,||,`,$(...),>,<, etc. rejected at the auto-injectedruntool's Zod boundary AND at theSubprocessShell.run()/CloudflareSandboxShell.run()boundary (defense-in-depth for directws.shell.run()callers from custom tools). - Glob/brace/wildcard expansion smuggling.
{,},*,?,[,],~rejected by default —cat /etc/{passwd,hostname}is blocked even whencatis in the allowlist. Operators opt in viacapabilities.shell.glob: trueonly when they have audited that the agent's allowed commands cannot be smuggled into filesystem enumeration. - 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 theenvfield to inject linker payloads (e.g. via a planted.so). - Command allowlist secure-by-default. As of round 4 (A1), an empty/undefined
allowedCommandsdenies ALL commands. Operators must explicitly opt in by listing permitted first-tokens. The historical "boolean true means allow everything" default is gone. - Cwd-escape rejection. Per-call
cwdoverrides are canonicalized viarealpathand rejected if they resolve outside the workspace tmpdir.
Pattern + input safety
- ReDoS heuristic + wall-clock backstop. Grep patterns with nested quantifiers, alternations with shared prefixes, or long
?-quantified runs are rejected at the Zod boundary byassertSafeGrepPattern. 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. - 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 incore/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
- Cross-session snapshot ownership. Snapshot refs carry an
originSandboxId. Round 5 (A5) requiresallowCrossSession: trueto restore a snapshot whose origin sandbox differs from the current session. Round 7 extended the same opt-in to the newSnapshotter.list()andSnapshotter.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. - Sandbox
config.idcross-session guard. Round 4 (A6) rejects an explicitconfig.idthat differs fromsession.sessionIdunlessshareAcrossSessions: trueis opted in. The opt-in carries a clear log warn so operators can audit every cross-session reuse. - 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
- Tamper-resistant ref payloads via Zod. Persisted refs (snapshot, workspace) are validated via
safeParseon 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. - Ref schema versioning. Round 4 (D4) added
schemaVersionto 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. - Capability invariant assertion. Round 2 added an
assertCapabilitiesSatisfiedcheck 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. - 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_filecould land on the old workspace mid-close.
Surface hygiene
- Tool-name collision detection.
workspace__andcompanion__prefixes are reserved. Custom tools using these prefixes fail-fast at agent-construction time with a clear error. - 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
warnthrough the configuredLogger. 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) addedsessionIdanduserIdto 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. - 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 addedSnapshotter.list()+Snapshotter.delete()and the corresponding auto-injected tools (workspace__<name>__list_snapshots,..._delete_snapshot); agents and operators can now bound R2 storage reactively. Thelist_snapshotstool clamps the LLM-suppliedlimitto 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 (
systemdslice withCPUQuota=/MemoryMax=,prlimitfor fd/proc caps,iptablesegress 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
| Provider | Isolation | Defenses | Suitable for |
|---|---|---|---|
in-memory (@helix-agents/workspace-memory) | None — pure JS Map | Tmpdir-equivalent path scoping; no shell/code | Dev/test ONLY. Data lost on close. |
local-bash (@helix-agents/workspace-local-bash) | Per-session tmpdir; no process isolation | Symlink + scope + allowlist + metachar + glob + env-deny + leaf-O_NOFOLLOW hardening | Trusted-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 + R2 | No shell/code; FS-only with ranged reads | Production note storage for durable agent memory. No code execution surface. |
cloudflare-sandbox (@helix-agents/runtime-cloudflare) | Linux container with kernel-namespace isolation | Allowlist + metachar + glob + workspaceDir scoping + audit logging | Untrusted-input production. The recommended provider for any deployment processing adversarial input on Cloudflare. |
temporal runtime | Workspaces unsupported | Fail-fast at run-start with assertRuntimeSupportsWorkspaces | N/A |
cloudflare workflows runtime | Workspaces unsupported | Same fail-fast | N/A |
Recommended posture per deployment shape
Local development
- Provider:
local-bashwith a narrow allowlist (['ls', 'cat', 'grep', 'git', 'npm']or similar). passEnv: leave default (minimal allowlist) — LLM cannot read host secrets viaprintenv.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) ORcloudflare-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 (
systemdslice withCPUQuota=/MemoryMax=,prlimitfor 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-bashis fine when running INSIDE a host sandbox; the sandbox boundary is the isolation boundary. Or usecloudflare-sandboxif 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-wasmand configureWorkspaceRegistryDeps.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/maxGlobalConcurrentOpensare 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
shareAcrossSessionsunless 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). maxGlobalConcurrentOpensis mainly relevant if multiple agent DOs share a single Sandbox DO binding. Each binding'smax_instancesis 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) snippetis bounded to 80 chars and high-confidence secrets are scrubbed via the round 4 (D5)scrubLikelySecretshelper 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 atwarnlevel. - Per-session keying (round 6 S4): the dedup key incorporates the bound
sessionIdso an adversarial loop in session A cannot suppress the FIRST occurrence of the same event in session B.
Recommended sinks
- Production: any structured-log destination — Datadog, Loki, CloudWatch, GCP Cloud Logging, Honeycomb. Filter on
level: warnAND aworkspaceName/workspaceIdfield 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/userIdso 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:
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
infolog 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:
| Gap | Why | Roadmap |
|---|---|---|
| TOCTOU ancestor-chain race (local-bash) | Node lacks openat2(2) with RESOLVE_BENEATH binding | Track 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-bash | TS framework limitation — HOST-LEVEL | External host sandbox provides this (e.g. gVisor runsc, container seccomp profiles). |
| No native namespace isolation on local-bash | Cross-platform constraint — HOST-LEVEL | External host sandbox provides PID/network/mount/UTS namespaces by default. |
| No native cgroup / ulimit enforcement on local-bash | HOST-LEVEL | External host sandbox provides cgroup CPU/memory limits. Bare host: systemd slice, container caps. |
| Capability extension point hard-coded | Round 3 cluster C — known limitation, documented in registry source | Future major-version refactor when the surface is stable |
| No tamper-evident audit chain | Operator's logging stack | Recommendation: ship to immutable sink |
| Resource-exhaustion attacks (no fd cap, no proc cap) | HOST-LEVEL | External host sandbox provides fd/process caps and resource limits. |
| Side-channel attacks (timing, cache, Spectre) | Architectural — process-level | Run isolation units in different processes (one orchestrator process per isolation unit inside its own sandbox). |
| Container/microVM escape | Delegated to upstream @cloudflare/sandbox or operator-chosen sandbox | Tracked 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.