{"id":50482322,"url":"https://github.com/eersnington/agent-container","last_synced_at":"2026-06-01T18:31:02.997Z","repository":{"id":353376356,"uuid":"1215945393","full_name":"eersnington/agent-container","owner":"eersnington","description":"Agent Container is a workerd-powered sandbox that gives coding agents isolated repo access via structured bindings — filesystem, shell, and env — without exposing the host. Ideal for AI coding tools, agent frameworks, and platforms running untrusted code.","archived":false,"fork":false,"pushed_at":"2026-04-23T17:13:47.000Z","size":156,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-23T17:33:00.423Z","etag":null,"topics":["code-execution","local-ai","sandbox","workerd"],"latest_commit_sha":null,"homepage":"https://git.new/agent-container","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/eersnington.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-20T12:11:15.000Z","updated_at":"2026-04-23T17:25:18.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/eersnington/agent-container","commit_stats":null,"previous_names":["eersnington/agent-container"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/eersnington/agent-container","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eersnington%2Fagent-container","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eersnington%2Fagent-container/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eersnington%2Fagent-container/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eersnington%2Fagent-container/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/eersnington","download_url":"https://codeload.github.com/eersnington/agent-container/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eersnington%2Fagent-container/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33789013,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-01T02:00:06.963Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["code-execution","local-ai","sandbox","workerd"],"created_at":"2026-06-01T18:31:02.263Z","updated_at":"2026-06-01T18:31:02.982Z","avatar_url":"https://github.com/eersnington.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Agent Container\n\n### Give your clankers tiny boxes, powered by [workerd](https://github.com/cloudflare/workerd)\n\n\u003e ⚠️ This project is under active development. APIs may change.\n\nAgent Container is a runtime layer for coding agent harnesses. It lets agent-generated code operate on a workspace through explicit bindings — `WORKSPACE`, `EXEC`, `ENV` — rather than raw host APIs like `fs`, `child_process`, or `process.env`.\n\nThe core idea: a workspace directory shouldn't simultaneously be the execution boundary, the filesystem authority boundary, and the environment boundary. Those are different concerns and should be controlled separately. The host owns real authority and projects only what's needed into `workerd` as live bindings:\n\n```ts\nconst pkg = await WORKSPACE.readText(\"package.json\");\nconst { stdout } = await EXEC.run({ command: \"node\", args: [\"--version\"] });\nconst apiUrl = await ENV.get(\"API_URL\");\n```\n\nrather than handing agent code raw host APIs:\n\n```ts\nconst pkg = await fs.readFile(\"/Users/me/project/package.json\", \"utf8\");\nconst { stdout } = await execFile(\"node\", [\"--version\"]);\nconst apiUrl = process.env.API_URL;\n```\n\nAgent Container gives coding agent harnesses a capability-bound execution model: guest code runs in `workerd`, while the Node.js host brokers filesystem access, subprocess execution, environment values, network policy, and observability through explicit, auditable bindings.\n\n---\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"#quick-start\"\u003eQuick Start\u003c/a\u003e \u0026middot;\n  \u003ca href=\"#why\"\u003eWhy\u003c/a\u003e \u0026middot;\n  \u003ca href=\"#threat-model\"\u003eThreat Model\u003c/a\u003e \u0026middot;\n  \u003ca href=\"#how-it-works\"\u003eHow It Works\u003c/a\u003e \u0026middot;\n  \u003ca href=\"#bindings\"\u003eBindings\u003c/a\u003e \u0026middot;\n  \u003ca href=\"#api\"\u003eAPI\u003c/a\u003e \u0026middot;\n  \u003ca href=\"#development\"\u003eDevelopment\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n## Quick Start\n\n```ts\nimport { createAgentContainer } from \"agent-container\";\n\nconst container = await createAgentContainer({\n  workspace: {\n    root: process.cwd(),\n    mode: \"shadow\", // run against a disposable copy of the workspace\n  },\n  env: {\n    include: [\"PUBLIC_*\", \"APP_*\"],\n  },\n  exec: {\n    allowedCommands: [\"node\", \"git\"],\n  },\n  network: {\n    allowFetch: false,\n  },\n});\n\nawait container.start();\n\nconst session = await container.createWorkerdSession();\n\nconst { result } = await session.run(\n  `\n    export async function run({ input, WORKSPACE, EXEC }) {\n      const pkg = await WORKSPACE.readText(input.packagePath);\n      const { stdout } = await EXEC.run({ command: \"node\", args: [\"--version\"] });\n      return { name: JSON.parse(pkg).name, node: stdout.trim() };\n    }\n  `,\n  {\n    language: \"ts\",\n    input: { packagePath: \"package.json\" },\n  },\n);\n\nconsole.log(result);\n// { name: \"my-project\", node: \"v22.0.0\" }\n\nawait session.stop();\nawait container.stop();\n```\n\nCode inside the `workerd` session does not get Node's `fs`, `process`, or `child_process` APIs. It gets the bindings the host chooses to expose.\n\n## Why\n\nMost coding agent harnesses provide tools like read, write, edit, grep, bash, and git — typically running on the host with the project directory as a soft boundary. That works until it doesn't:\n\n- A read tool resolves paths on the host filesystem — an agent passes `../../.ssh/id_rsa` and it just works\n- A bash tool inherits the full parent process environment, so every secret in process.env is silently available to anything the agent runs\n- A grep over \"the project\" follows a symlink outside the workspace root without anyone noticing\n- A subprocess writes to a path outside the workspace because cwd resolution was never constrained\n\nThere's no audit of what the agent actually read, wrote, or executed; just a process and a directory, and an assumption they stayed inside the lines\n\nNone of this requires malicious intent. Your clanker can sometimes be daft, hallucinate a path, or following bad instructions that can cause real damage through tools that were never designed to say no.\n\nAgent Container replaces that assumption with explicit capability bindings. `WORKSPACE` scopes filesystem access to the project root and enforces path containment. `EXEC` runs only allowlisted commands with controlled cwd resolution and logged outcomes. `ENV` and `SECRETS` expose only what you explicitly include; nothing leaks in from `process.env` by default. Network access is configured at the session level rather than inherited. Every operation crosses a bridge the host controls, which means there's an actual record of what the agent did.\n\nThis follows the Cloudflare Workers resource model, where bindings carry both permission and API as runtime objects. In an agent harness, the same model maps cleanly to the resources an agent needs for coding work.\n\n## Threat Model\n\nAgent Container is **not a secure sandbox for fully untrusted code**.\n\nIt reduces ambient authority by moving access behind explicit bindings, but the host still brokers real filesystem and subprocess operations. `EXEC.run` starts real host subprocesses. `WORKSPACE` maps to real files or a copied workspace. The bridge is session-local and token-gated, but it is not a substitute for VM-level, container-level, or kernel-level isolation when running adversarial code.\n\nNote that `workerd` network policy applies to the guest runtime only — not to subprocesses started through `EXEC.run`. A permitted command runs as a host subprocess with the configured cwd, environment projection, timeout, and command policy applied.\n\nThe goal is narrower and more practical for coding agents: don't give generated code broad host authority by default. Give it explicit, inspectable, constrained capabilities instead.\n\n## How It Works\n\n```\nHOST (Node.js)\n\n  Workspace Controller       Env Resolver          Exec Controller\n  - live/shadow roots        - .env files          - command allowlist\n  - ro/rw mounts             - inline values       - cwd inside workspace\n  - path containment         - process env policy  - timeouts\n  - list/stat/glob/grep      - secret classes      - structured results\n\n            \\                    |                    /\n             \\                   |                   /\n              +--------- Capability Bridge ----------+\n                        localhost HTTP + token\n                                 |\n                                 v\n\nGUEST (workerd)\n\n  JavaScript runs with explicit bindings:\n\n  WORKSPACE    EXEC        ENV        SECRETS      OBSERVE\n  read/write   run/shell   get/keys   get/keys     emit\n  list/stat\n  glob/grep\n  remove\n```\n\nThe `workerd` harness runs JavaScript or TypeScript modules and passes capability bindings through a `run(ctx)` export. Those binding methods call a session-local bridge. The bridge validates JSON requests, checks the configured policy through the host controllers, performs the operation, and emits observability events when configured.\n\n## Current Surface\n\nImplemented today:\n\n- `createAgentContainer(options)` assembles workspace, env, exec, network, and observability policy.\n- `container.createWorkerdSession()` starts a real `workerd` process with a generated config.\n- `WORKSPACE` supports `readText`, `writeText`, `list`, `stat`, `glob`, `grep`, and `remove`.\n- Workspace mode can be `live` or `shadow`; `shadow` copies the workspace to a disposable temp directory.\n- Workspace mounts can expose additional paths as read-only or read-write logical mount points.\n- Workspace reads are env-aware: root `.env*` sources are exposed as filtered dotenv views, and env-like files outside configured env sources are denied.\n- `workspace.denyRead` can deny additional non-env paths from `WORKSPACE.readText` and `WORKSPACE.grep`.\n- `EXEC.run` starts allowlisted host commands with workspace-scoped cwd resolution, timeout handling, and selected env projection.\n- `EXEC.shell` exists, but only works when `allowShell` is enabled.\n- `ENV` exposes public variables and `SECRETS` exposes secret-classified variables.\n- `OBSERVE.emit` lets guest code add structured events to the host observability sink.\n- `workerd` outbound fetch is disabled by default and can be enabled with optional origin filtering.\n- `session.run()` accepts code or workspace path sources, transpiles TypeScript/TSX per file, and preserves the `workerd` module graph for static relative imports.\n\nNot implemented yet (WIP):\n\n- a first-class `NET` binding\n- a first-class `GIT` binding\n- narrow workspace change primitives such as `diff`, `statusSummary`, `snapshot`, and `applyPatch`\n\n## Bindings\n\n### WORKSPACE\n\n`WORKSPACE` is the project-shaped view given to agent code.\n\n```ts\nconst content = await WORKSPACE.readText(\"src/index.ts\");\nawait WORKSPACE.writeText(\"notes/result.json\", JSON.stringify(data, null, 2));\n\nconst entries = await WORKSPACE.list(\"src\");\nconst info = await WORKSPACE.stat(\"package.json\");\n\nconst files = await WORKSPACE.glob([\"src/**/*.ts\", \"README.md\"]);\nconst matches = await WORKSPACE.grep(\"TODO\", {\n  include: \"**/*.ts\",\n  caseSensitive: false,\n  maxResults: 20,\n});\n```\n\nModes:\n\n- `live` operates on the configured root.\n- `shadow` copies the configured root to a temporary directory and operates there.\n\nMounts:\n\n```ts\nconst container = await createAgentContainer({\n  workspace: {\n    root: process.cwd(),\n    mounts: [\n      { mountPath: \"/docs\", sourcePath: \"/path/to/docs\", mode: \"ro\" },\n      { mountPath: \"/scratch\", sourcePath: \"/path/to/scratch\", mode: \"rw\" },\n    ],\n  },\n});\n```\n\nThe workspace controller resolves logical paths against the matching mount and rejects path traversal outside that mount's physical root.\n\nEnv file reads:\n\n```ts\nconst container = await createAgentContainer({\n  workspace: { root: process.cwd() },\n  env: {\n    include: [\"PUBLIC_*\"],\n    processEnv: \"none\",\n  },\n});\n\nconst envFile = await WORKSPACE.readText(\".env\");\n// PUBLIC_READ_KEY=\"hello-world\"\n```\n\nIf `env.sources` is omitted, root-level `.env` and `.env.*` files are treated as env sources. Reading one of those files through `WORKSPACE` returns a synthetic dotenv file filtered by the same `env.include` and `env.exclude` rules used by `ENV`.\n\nIf `env.sources` is provided, only those file sources are readable as filtered env files. Other env-like files, such as `.env.local` when only `.env` is configured, are denied with `Path is not readable: \u003cpath\u003e`.\n\nAdditional read denies:\n\n```ts\nconst container = await createAgentContainer({\n  workspace: {\n    root: process.cwd(),\n    denyRead: [\"**/*.secret\"],\n  },\n});\n```\n\n`denyRead` applies to non-env file content reads and search. It does not select env sources; use `env.sources` for that. `list`, `stat`, and `glob` may still reveal filenames.\n\n### EXEC\n\n`EXEC` is brokered subprocess execution.\n\n```ts\nconst result = await EXEC.run({\n  command: \"node\",\n  args: [\"--version\"],\n  timeoutMs: 5_000,\n});\n\nconsole.log(result.stdout, result.exitCode);\n```\n\nPolicy:\n\n```ts\nconst container = await createAgentContainer({\n  workspace: { root: process.cwd() },\n  exec: {\n    allowedCommands: [\"node\", \"git\"],\n    allowShell: false,\n    defaultTimeoutMs: 30_000,\n  },\n});\n```\n\nEnvironment projection into subprocesses is explicit:\n\n```ts\nawait EXEC.run({\n  command: \"node\",\n  args: [\"script.js\"],\n  envKeys: [\"PUBLIC_MODE\"],\n  env: { EXTRA_FLAG: \"1\" },\n});\n```\n\nSecrets are excluded from subprocess env by default, even when listed in `envKeys`. Use `includeSecrets: true` only when the command genuinely needs them.\n\n### ENV and SECRETS\n\n`ENV` and `SECRETS` expose selected configuration values without giving the guest raw `process.env`.\n\n```ts\nconst mode = await ENV.get(\"PUBLIC_MODE\");\nconst publicKeys = await ENV.keys();\n\nconst token = await SECRETS.get(\"API_SECRET_TOKEN\");\nconst secretKeys = await SECRETS.keys();\n```\n\nEnv policy can load from root `.env*` files, explicit file sources, inline values, and selected process env values:\n\n```ts\nconst container = await createAgentContainer({\n  workspace: { root: process.cwd() },\n  env: {\n    include: [\"PUBLIC_*\", \"API_SECRET_*\"],\n    exclude: [\"PUBLIC_DEBUG_ONLY\"],\n    processEnv: \"allow-matching\",\n    secretPatterns: [\"*_KEY\", \"*_TOKEN\", \"*_SECRET\", \"*_PASSWORD\"],\n  },\n});\n```\n\nWhen `sources` is omitted, Agent Container discovers root-level `.env` and `.env.*` files. When `sources` is provided, only those sources are used.\n\n```ts\nconst container = await createAgentContainer({\n  workspace: { root: process.cwd() },\n  env: {\n    sources: [{ type: \"file\", path: \".env\" }],\n    include: [\"PUBLIC_*\"],\n    processEnv: \"none\",\n  },\n});\n```\n\n### Network\n\nThere is no first-class `NET` binding yet. Current network policy controls `workerd`'s global outbound fetch behavior:\n\n```ts\nconst session = await container.createWorkerdSession({\n  allowFetch: true,\n  allowedFetchOrigins: [\"api.example.com\"],\n});\n```\n\nBy default, outbound fetch is blocked. If `allowedFetchOrigins` is set, the generated `workerd` config routes requests through a filtering worker before public network access.\n\n### OBSERVE\n\nHost-side controllers emit structured events for container lifecycle, workspace operations, env resolution, exec outcomes, and `workerd` runs.\n\n```ts\nconst events = [];\n\nconst container = await createAgentContainer({\n  workspace: { root: process.cwd() },\n  observability: {\n    emit(event) {\n      events.push(event);\n    },\n  },\n});\n```\n\nGuest code can also emit events:\n\n```ts\nawait OBSERVE.emit({\n  scope: \"workspace\",\n  action: \"custom-check\",\n  outcome: \"success\",\n  detail: \"validated generated files\",\n});\n```\n\n## API\n\n### createAgentContainer(options)\n\n```ts\ninterface AgentContainerOptions {\n  workspace: {\n    root: string;\n    mode?: \"live\" | \"shadow\";\n    mounts?: readonly {\n      mountPath: string;\n      sourcePath: string;\n      mode: \"ro\" | \"rw\";\n    }[];\n    denyRead?: readonly string[];\n  };\n  env?: {\n    sources?: readonly EnvSource[];\n    include?: readonly string[];\n    exclude?: readonly string[];\n    publicPatterns?: readonly string[];\n    secretPatterns?: readonly string[];\n    processEnv?: \"none\" | \"allow-matching\" | \"all\";\n  };\n  exec?: {\n    allowedCommands?: readonly string[];\n    allowShell?: boolean;\n    defaultTimeoutMs?: number;\n  };\n  network?: {\n    allowFetch?: boolean;\n    allowedFetchOrigins?: readonly string[];\n  };\n  observability?: {\n    emit(event: ObservabilityEvent): void | Promise\u003cvoid\u003e;\n  };\n}\n```\n\n### container.createWorkerdSession(options?)\n\n```ts\nconst session = await container.createWorkerdSession({\n  startupTimeoutMs: 30_000,\n  compatibilityDate: \"2026-04-20\",\n  allowFetch: false,\n});\n\nconst { result, logs, durationMs } = await session.run(\n  { path: \"scripts/check.ts\" },\n  { timeoutMs: 5_000 },\n);\n\nawait session.stop();\n```\n\n### defineAgentContainerPlugin(options)\n\nDefines a small plugin descriptor that agent harnesses can use to map their tool names to Agent Container bindings.\n\n```ts\nconst plugin = defineAgentContainerPlugin({\n  name: \"my-agent\",\n  container: {\n    workspace: { root: \".\" },\n    exec: { allowedCommands: [\"node\"] },\n  },\n  tools: {\n    read: \"WORKSPACE.readText\",\n    bash: \"EXEC.run\",\n  },\n});\n```\n\n## Project Structure\n\n```\npackages/\n├── agent-container/    # Core runtime, controllers, bridge, workerd session\n├── types/              # Shared TypeScript types\n├── cli/                # CLI tools\n└── test-utils/         # Test helpers\n\napps/\n├── e2e/                # End-to-end harness tests\n├── playground/         # Development playground (WIP)\n└── docs/               # Documentation (WIP)\n```\n\n## Development\n\n```sh\npnpm install\npnpm build\npnpm typecheck\npnpm test\npnpm test:e2e\n```\n\n### CLI (WIP)\n\n```sh\nagent-container describe\n```\n\n`describe` prints the container description for the current directory.\n\n\n## Acknowledgements\n\nThanks to [rivet-dev/secure-exec](https://github.com/rivet-dev/secure-exec) — Agent Container was partly inspired by their implementation and the thinking behind it.\n\n## License\n\n[Apache-2.0](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feersnington%2Fagent-container","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Feersnington%2Fagent-container","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feersnington%2Fagent-container/lists"}