Skip to content

Repository files navigation

Workcell

Secure workspaces for autonomous agents.

Workcell is a self-hostable runtime for team agents: every person, channel, and project gets an isolated workspace, scoped memory, explicit credential grants, and an audit trail. The goal is simple: let agents do real work with real tools without handing the model ambient access to everything.

Status: V1, self-hosted, actively evolving. Before relying on it for anything sensitive, read docs/threat-model.md — in particular, Docker is the current sandbox isolation and it is explicitly not a hard security boundary against kernel/container escape.

Why this exists

Most agent products start as chat UIs and add safety later. Workcell starts at the runtime boundary:

  • Scope isolation: personal, channel, project, and org scopes are first-class.
  • Credential control: secrets live in the runtime keychain, not in prompts. Grants are purpose-labeled, scoped, expiring, and audited.
  • Auditable execution: every credential save, grant, materialization, and tool decision becomes an audit event.
  • Sandbox-first design: each scope maps to a durable, isolated workspace. Docker is the V1 substrate; microVMs are the production hardening path.

V1 wedge

The first product slice is a secure agent workspace for small teams:

  1. Telegram/Web entrypoint for dogfooding.
  2. Docker-backed workspace per scope.
  3. Minimal tool surface: terminal, file read/write, web fetch.
  4. Credential vault with once/standing grants.
  5. Run timeline and audit log.

Repository status

This is the initial scaffold. Implemented and tested:

  • canonical scope IDs and path-safe storage keys
  • in-memory encrypted credential vault
  • once/standing credential grants
  • command policy floor for destructive shell patterns
  • in-memory audit log
  • in-memory run lifecycle store with queued/running/completed/failed transitions
  • terminal tool router with command policy and approval enforcement
  • minimal agent loop that can complete model responses, execute terminal tool calls, and pause for approval
  • Docker sandbox executor with per-scope workspace volumes, no-network default, timeouts, and output caps
  • scoped workspace file tools with traversal/symlink rejection and read output caps
  • OpenAI-compatible Chat Completions model adapter with terminal/read_file/write_file/web_fetch tool calls
  • minimal CLI runner for local deterministic smoke runs
  • minimal Fastify HTTP API for health checks and run create/read
  • pending approval store plus HTTP approval resume for gated terminal tool calls
  • optional HTTP actor token auth with per-actor scope authorization
  • SQLite-backed runtime state for runs, timelines, audit events, and pending approvals
  • Telegram update connector primitive for message-to-run and /approve <runId> dogfooding
  • Telegram webhook/Bot API shell with Telegram secret verification and injected fetch for tests
  • unified workcell server entrypoint that mounts HTTP runs and optional Telegram webhook on one Fastify app
  • local Docker/Compose deployment package with persistent volumes and server smoke script
  • probable-secret scanning that blocks workspace writes, secret-bearing terminal commands, and Telegram delivery of concrete credential material while allowing obvious placeholders
  • redaction of probable secret material before tool results are replayed to the model or persisted in run outputs/errors and audit fields
  • SQLite persistence minimization for secret-bearing run inputs and pending-approval messages while preserving exact pending tool steps for resume
  • opt-in provider request capture controls with metadata-only or redacted/truncated JSONL output for operator debugging
  • keychain-backed terminal credential bindings that materialize granted secrets into executor env only at execution time instead of embedding them in shell literals, with atomic once-grant consumption across one command and exact pending-approval step snapshots for restart-safe resume

Development

Requires Node.js 24+. The test suite runs .ts files directly via Node's native TypeScript support, which does not exist on Node 22 and earlier — npm run verify will fail with ERR_UNKNOWN_FILE_EXTENSION on older Node.

npm install
npm run verify

better-sqlite3 has a native build step. npm's install-script safety gate blocks it by default, so a fresh npm install leaves the module unbuilt and any SQLite-backed test (sqlite-store, http-api, server, workcell-runner) fails with Cannot find module 'better-sqlite3'. Approve and build it once:

npm approve-scripts better-sqlite3
npm rebuild better-sqlite3

Run the Docker-backed integration smoke test when Docker is available:

npm run test:docker

If the current shell has not picked up Docker group membership yet, run it through the docker group:

sg docker -c 'npm run test:docker'

Run the Postgres-backed persistent store tests against a disposable local database:

npm run test:pg:up
npm run test:pg
npm run test:pg:down

Each test creates and drops its own Postgres schema, so the tests are safe to run repeatedly against the same database.

Persistent credential vault (Postgres)

By default the credential vault is in-memory and empty after every restart. To persist it, point Workcell at a real Postgres database and apply the schema first:

export WORKCELL_DATABASE_URL=postgres://workcell:change-me@localhost:5432/workcell
npm run cli -- migrate

workcell migrate is an explicit, operator-run step — nothing in the server or CLI runs migrations automatically, so multiple server instances never race to migrate the same database. It applies the numbered .sql files in migrations/ in order and records each one in a schema_migrations table, so re-running it is always safe (it only applies what's new). Set WORKCELL_DATABASE_URL and WORKCELL_MASTER_KEY_HEX (see .env.example) on the server itself to actually use the persistent vault at runtime.

Adding a schema change later means adding a new migrations/000N_description.sql file — never editing an already-applied one — and running workcell migrate again.

Run the deterministic local CLI demo:

npm run cli -- run "hello from Workcell" --scope project:demo

Without an API key, the CLI uses a tiny deterministic demo model that writes the prompt into the scoped workspace at demo/last-run.txt. With OPENAI_API_KEY, it switches to the OpenAI-compatible Chat Completions adapter; override with OPENAI_BASE_URL and OPENAI_MODEL.

Run the local server entrypoint:

WORKCELL_STATE_DB=.workcell/state/workcell.db \
WORKCELL_DEV_TOKEN=<shared-dev-token> \
WORKCELL_AUTH_SCOPES_JSON='{"user:birand":["project:demo"]}' \
npm run cli -- server

Server defaults bind to 127.0.0.1:8787. Binding to anything outside localhost is refused unless WORKCELL_ALLOW_PUBLIC_BIND=1 is set. Telegram webhook mode is enabled only when both WORKCELL_TELEGRAM_BOT_TOKEN and WORKCELL_TELEGRAM_WEBHOOK_SECRET are present; partial Telegram config is rejected at boot.

WORKCELL_DEV_TOKEN is a single shared secret every actor sends as-is; it is fine for local development but not for production, since possession of the token lets a caller claim any actor identity. For production, sign requests per actor instead:

WORKCELL_SIGNING_KEYS_JSON='{"user:birand":"<64-hex-character secret, one per actor>"}'
WORKCELL_AUTH_SCOPES_JSON='{"user:birand":["project:demo"]}'

Each request must carry X-Workcell-Actor, X-Workcell-Timestamp (Unix ms), and X-Workcell-Signature (hex HMAC-SHA256 over METHOD\nPATH\nTIMESTAMP\nRAW_BODY, keyed by that actor's secret). Signatures older than WORKCELL_SIGNATURE_TTL_MS (default 5 minutes) are rejected. The signWorkcellRequest helper in src/http-api.ts builds these headers for Node clients. If both WORKCELL_DEV_TOKEN and WORKCELL_SIGNING_KEYS_JSON are set, a request is authenticated with signing headers when present and falls back to the shared token otherwise, so you can migrate actors incrementally.

Enable the allowlisted web fetch tool only for hosts you trust to provide model-readable content:

WORKCELL_WEB_FETCH_ALLOWLIST=docs.example.com,*.developer.mozilla.org

The runtime denies web_fetch calls when the allowlist is empty. Allowed fetches are HTTPS-only (except loopback HTTP for local dev), reject URL credentials and redirects, cap response bytes, and label the returned body as untrusted data. Fetched content is also scanned for common prompt-injection phrasing (for example "ignore previous instructions" or "reveal your system prompt"); a match doesn't block or alter the content, but strengthens the untrusted-data warning sent to the model and adds an audit detail so operators can see it happened.

The sandbox defaults to --network none, so terminal commands have no network access at all. If a scope genuinely needs outbound access (git clone, npm install), route it through the same host allowlist as web_fetch instead of opening the network entirely:

import { createEgressProxy } from "./src/egress-proxy.ts";
import { createDockerSandbox } from "./src/docker-sandbox.ts";

const proxy = createEgressProxy({ allowlist: ["github.com", "*.npmjs.org"], audit });
const { port } = await proxy.listen(0, "0.0.0.0");

const sandbox = createDockerSandbox({
  workspacesRoot: ".workcell/workspaces",
  network: "bridge",
  egressProxyUrl: `http://host.docker.internal:${port}`,
});

The proxy only permits CONNECT tunnels to allowlisted hosts and denies (and audits) everything else; it does not inspect TLS content. Reaching the proxy from inside the container requires real container network access (network: "bridge" or similar) and a host address the container can resolve — host.docker.internal works out of the box on Docker Desktop, but Linux hosts typically need --add-host=host.docker.internal:host-gateway added via Docker's own extra_hosts configuration. This is a building block for embedders, not an automatic env-driven setting: createWorkcellRuntime still defaults to no network for the sandbox.

Workspace writes, terminal command requests, and outbound Telegram delivery also scan for probable concrete secrets (for example OpenAI keys, GitHub tokens, Telegram bot tokens, AWS keys, private key blocks, and secret-looking credential assignments). Matching content is blocked and the operator is told to redact it or move it into the Workcell keychain. Obvious placeholders such as change-me and example are allowed.

When a runtime is configured with a credential vault, terminal tool calls can also pass credential_bindings that map granted credential IDs onto env var names such as GITHUB_TOKEN. The shell command keeps the variable reference, while Workcell materializes the secret only at execution time and passes the env var name to Docker without embedding the secret value in the command string. If one command binds the same credential into multiple env vars, Workcell materializes that credential once and fans the secret out across the requested env vars without double-spending the once grant. Pending approvals snapshot that exact tool step before resume, so later callers cannot mutate the stored command text or credential bindings by holding onto an earlier in-memory object.

When tools do return secret-looking material, Workcell now redacts those values before replaying tool results back into model-visible context and before persisting run outputs/errors or audit resource/detail fields. This keeps likely credentials out of provider prompts, HTTP/Telegram run output surfaces, and SQLite-backed audit trails.

For provider debugging, OpenAI-compatible calls can optionally write JSONL captures outside the scoped workspace:

WORKCELL_PROVIDER_CAPTURE_MODE=metadata
WORKCELL_PROVIDER_CAPTURE_FILE=.workcell/provider-captures/openai.jsonl

metadata records request/response summaries only. redacted stores sanitized and truncated message/response content with probable secrets replaced, and neither mode stores the Authorization header value.

Run the Docker Compose dogfood package:

cp .env.example .env
docker compose up --build -d
npm run smoke:server

Compose persists SQLite state and scoped workspaces in named volumes and maps the service to 127.0.0.1:${WORKCELL_HOST_PORT:-8787}. See docs/deployment.md for operator notes.

Use the HTTP API factory in local/dev hosts:

import { createHttpApi } from "./src/http-api.ts";

const app = await createHttpApi({
  workspacesRoot: ".workcell/workspaces",
  env: process.env,
  auth: {
    token: process.env.WORKCELL_DEV_TOKEN!,
    scopesByActor: { "user:birand": ["project:demo"] },
  },
});
await app.listen({ host: "127.0.0.1", port: 3030 });

Available routes:

  • GET / — web UI (see below)
  • GET /healthz
  • POST /runs with { "prompt": "...", "scopeId": "project:demo" }
  • GET /runs?scopeId=&actorId=&limit= — most recent runs first. scopeId is required for non-admin actors; admins can omit it or filter by actorId across scopes.
  • GET /runs/:id
  • POST /runs/:id/approve
  • GET /files?scopeId=&path= — list a workspace directory (path defaults to the workspace root)
  • GET /files/content?scopeId=&path= — read a workspace file
  • POST /credentials with { "service": "github", "secret": "...", "createdFromScope": "personal:user:birand" } — saves a credential owned by the authenticated actor (createdFromScope defaults to personal:<actorId>); requires a configured vault
  • GET /credentials — the authenticated actor's own credential metadata (never the secret value)
  • POST /grants with { "credentialId": "...", "scopeId": "project:demo", "mode": "once" | "standing", "purpose": "...", "expiresInMs": 3600000 } — grants a credential the actor owns to a scope the actor is authorized for
  • GET /grants — the authenticated actor's own credential grants
  • GET /admin/audit?scopeId=&limit= — full cross-scope audit trail, newest first (capped at 500). Requires the calling actor to be listed in WORKCELL_ADMIN_ACTORS (comma-separated actor IDs); admin actors also bypass per-scope authorization on every other route.
  • GET /admin/credentials?ownerId= — credential metadata for any owner (never the secret value), admin only
  • GET /admin/grants?ownerId=&credentialId= — credential grants for any owner/credential, admin only

When auth is configured, non-health routes require:

  • X-Workcell-Actor: user:birand
  • X-Workcell-Token: <shared dev token>

The token is compared with timingSafeEqual; the actor must be authorized for the target scope. Approval is additionally owner-bound: an actor cannot approve another actor's waiting run even when both actors can access the same scope.

WORKCELL_DEV_TOKEN is a shared-secret development boundary; use WORKCELL_SIGNING_KEYS_JSON (see above) for production ingress.

The server rate-limits every route except /healthz, keyed by actor id (falling back to IP for unauthenticated requests) so one actor being throttled doesn't affect another. Defaults to 300 requests/minute; override with WORKCELL_RATE_LIMIT_MAX and WORKCELL_RATE_LIMIT_WINDOW_MS, or set WORKCELL_RATE_LIMIT_MAX=0 to disable. createHttpApi's rateLimit option is undefined (off) by default — the loadWorkcellServerConfig/workcell server path is what turns it on by default.

Web UI

GET / serves a single self-contained HTML/JS page (no build step, no framework, no new dependency) with four tabs — Runs, Files, Keychain, Audit — backed entirely by the JSON routes above. Enter an actor ID and token to connect; the page stores them in sessionStorage only. Any connected actor can save their own credentials, grant them to scopes they're authorized for, and browse their own runs/files/credentials/grants from the Keychain tab's "My credentials" section. The Audit tab and the Keychain tab's "Admin view" (arbitrary-owner lookup) require an admin actor (WORKCELL_ADMIN_ACTORS). Credential secret values are sent once on save and never sent back to the browser afterward. Signed-request auth is not supported from the browser UI (the HMAC secret would have to live in page JS, which defeats the point); use the token auth mode for interactive UI use, or build a dedicated client for signed automation.

Use the Telegram connector primitive when wiring a bot/webhook shell:

import { createTelegramConnector, createWorkcellRuntime } from "./src/index.ts";

const runtime = createWorkcellRuntime({ workspacesRoot: ".workcell/workspaces", env: process.env });
const telegram = createTelegramConnector({ runtime });
const actions = await telegram.handleUpdate(update);

It maps Telegram users to telegram:<userId> actors, private chats to personal:telegram/<userId>, and group/topic chats to channel:telegram/<chatId>/thread/<threadId>. It returns send-message actions instead of calling the Telegram network directly. Waiting runs are formatted with /approve <runId> instructions; approval replies are owner- and scope-bound before approveRun is called.

Use the webhook shell when you want a Fastify route that verifies Telegram's webhook secret header and executes connector actions through the Bot API:

import { createTelegramConnector, createTelegramWebhookApi, createWorkcellRuntime } from "./src/index.ts";

const runtime = createWorkcellRuntime({ workspacesRoot: ".workcell/workspaces", env: process.env });
const connector = createTelegramConnector({ runtime });
const app = await createTelegramWebhookApi({
  connector,
  botToken: process.env.TELEGRAM_BOT_TOKEN!,
  secretToken: process.env.TELEGRAM_WEBHOOK_SECRET!,
});
await app.listen({ host: "127.0.0.1", port: 3030 });

The route is POST /telegram/webhook; it requires X-Telegram-Bot-Api-Secret-Token. Bot API sendMessage failures become structured 502 responses. Real Telegram network smoke is intentionally opt-in via environment/config, not part of default tests.

Enable persistent runtime state with an explicit SQLite DB path:

const runtime = createWorkcellRuntime({
  workspacesRoot: ".workcell/workspaces",
  stateDbPath: ".workcell/state/workcell.db",
  env: process.env,
});

When stateDbPath is set, Workcell persists runs, run timelines, audit events, and pending approval state. Persisted run inputs plus pending approval message history are sanitized for probable secret material before they land in SQLite; pending approval tool steps stay exact so approval resume still replays the original gated action after restart. Secret-bearing terminal commands are denied before approval state is created, which keeps normal pending-step persistence free of raw credential literals even though the exact step remains restart-safe. The default remains in-memory for throwaway local demos.

Package layout

src/
  agent-loop.ts  minimal model/tool/run orchestration loop
  audit.ts      append-only audit log primitive
  cli.ts        minimal CLI entrypoint
  docker-sandbox.ts Docker-backed terminal executor
  docker-sandbox.integration.test.ts real Docker smoke test, skipped unless RUN_DOCKER_TESTS=1
  http-api.ts  Fastify HTTP API factory
  keychain.ts   encrypted credential vault and scoped grants
  openai-compatible.ts OpenAI-compatible Chat Completions model adapter
  policy.ts     command policy evaluator
  run-store.ts  run lifecycle and timeline primitive
  scope.ts      canonical scope IDs and storage keys
  secret-scanner.ts probable-secret detector and redaction helpers
  provider-capture.ts opt-in provider capture sink + sanitization helpers
  server.ts     unified config-driven Fastify server entrypoint
  sqlite-store.ts SQLite-backed run/audit/pending approval stores
  telegram-connector.ts Telegram update-to-runtime adapter
  telegram-webhook-api.ts Telegram webhook route and Bot API sender shell
  tool-router.ts typed tool gate for terminal execution
  web-fetch.ts allowlisted text fetcher with provenance labeling
  workspace-files.ts scoped read/write file tools
  workcell-runner.ts stateful local runtime assembly for CLI/API/demo runs

docs/
  architecture.md
  deployment.md
  threat-model.md
  v1-plan.md
  security-annex.md

License

MIT

About

Secure workspaces for autonomous agents

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages