just-bash

repository·main·Indexed 26 days ago

https://github.com/vercel-labs/just-bash

A monorepo providing a simulated bash environment with a virtual filesystem designed for controlled shell execution. It includes @just-bash/executor for defining inline tools or using SDK-driven discovery (GraphQL, OpenAPI, MCP), and supports AI agent integration via bash-tool for natural language interaction with the filesystem.

Tokens
51.6K
Snippets
131
Records
274
Agent score
88%

What's inside just-bash

  1. Understand the just-bash architecture

    main

    The just-bash website architecture consists of a Browser layer and a Server layer:

    Browser Components

    • just-bash (Browser): A pure TypeScript bash interpreter that runs locally. It uses an in-memory virtual filesystem for basic commands (ls, cat, grep) without network calls.
    • xterm.js: Renders the terminal, handles keyboard input, and supports ANSI escape codes.
    • agent command: A custom command that triggers the server-side AI agent via SSE.

    Server Components

    • ToolLoopAgent (AI SDK): Uses Anthropic's Claude Haiku model to loop through thinking, tool calling, and observation. It stops after 20 tool calls or when the task is complete.
    • bash-tool: Provides the agent with tools: bash (execute commands), readFile (read contents), and writeFile (write files, though disabled in the demo).
    • OverlayFS: Overlays the real filesystem (the source code) as read-only. The agent can explore the source, but all writes are directed to memory, not the actual disk.
  2. Understand just-bash security defenses and attack surfaces

    main

    The just-bash project implements multiple layers of defense to mitigate risks from untrusted scripts, malicious data, and compromised dependencies. The threat model covers several key attack surfaces:

    • Script Input (Parser): Protects against token bombs, parser stack overflows, and oversized inputs using limits like MAX_TOKENS and MAX_INPUT_SIZE.
    • Expansion & Substitution: Mitigates brace expansion bombs, command substitution depth, and glob bombs via strict limits (e.g., maxBraceExpansionResults, maxGlobOperations).
    • Filesystem: Prevents path traversal and symlink escapes using path normalization, root containment (isPathWithinRoot()), and an OverlayFs that writes to memory only.
    • Network: Disables network access by default. When enabled via NetworkConfig, it enforces protocol allow-lists (HTTP/HTTPS only) and validates redirects.
    • Code Execution Escape: Uses a defense-in-depth proxy to block dangerous globals like eval(), new Function(), setTimeout(string), and access to native modules via process.binding() or process.dlopen().
    • Information Disclosure: Blocks access to sensitive host information like process.env, process.argv, and host PIDs/UIDs (which are virtualized via the processInfo option).
    • Denial of Service (DoS): Implements limits on loop iterations (maxLoopIterations), call depth (maxCallDepth), and command counts (maxCommandCount) to prevent fork bombs and infinite loops.
    • Prototype Pollution: Uses null-prototype objects (Object.create(null)) for environment variables, AWK variables, and associative arrays to prevent property injection.
  3. Understand the just-bash Security Model

    main

    The just-bash shell operates under a specific security model designed to mitigate risks while running in a Node.js environment:

    • Filesystem Access: The shell only has access to the provided filesystem.
    • Execution Isolation: Execution happens without VM isolation. While designed to be robust against prototype-pollution and breakouts, it carries inherent risks.
    • Network Access: Disabled by default. When enabled, requests are validated against URL prefix and HTTP-method allow-lists.
    • Runtime Execution: python3/python and js-exec are disabled by default to reduce the security surface.
    • Resource Limits: Protection against infinite loops and deep recursion is provided via configurable limits.
    • Defense-in-Depth: Uses scoped controls available on the Node runtime. If node:module.registerHooks() is available, builtin ESM imports can be denied for the untrusted async context.
    • Intrinsic Protection: Uses reversible proxies for Reflect, JSON, and Math.
      • intrinsicProtection: "scoped-best-effort": Indicates same-realm JavaScript that cached an intrinsic before activation cannot be fully revoked.
      • processLifetimeIntrinsicHardening: true: Permanently freezes these objects and locks selected Symbol descriptors. Use this only in disposable or process-lifetime realms.
    • Memory Containment: Node worker resourceLimits may not reliably cap WebAssembly linear memory used by CPython or sql.js. Strong containment requires process/container isolation.

    If you require a full VM with arbitrary binary execution, use Vercel Sandbox.

  4. Understand the just-bash security model and trust boundaries

    main

    just-bash is a sandboxed TypeScript bash interpreter designed for AI agents. It operates under a zero-trust model for script inputs and external data.

    Key Security Principles:

    • Untrusted Scripts: Any bash script submitted to the interpreter is treated as having zero trust. The goal is to prevent sandbox escapes, host filesystem access, and secret exfiltration.
    • Untrusted Data: Data from external sources (HTTP, stdin, files) is treated as untrusted and can be used to attempt prototype pollution or injection attacks.
    • Host Trust: The host application (the code embedding just-bash) is considered trusted. Any fs, fetch, customCommands, or transform plugins provided by the host can bypass the sandbox. just-bash protects the host from the script, not the host from itself.
    • Sandbox Boundaries: The interpreter is confined by limits on tokens, input size, command counts, loop iterations, and call depth. It uses an in-memory virtual filesystem and has no access to spawn(), host environment variables, or Node.js internals by default.
  5. Configure MCP sources in @just-bash/executor

    main

    You can add Model Context Protocol (MCP) servers as tool sources using sdk.sources.add. MCP servers can be connected via remote (SSE/HTTP) or stdio (local process) transports.

    Requirements:

    • For transport: "remote", you must provide an endpoint.
    • For transport: "stdio", you must provide a command and args.
    • You must install @executor-js/plugin-mcp alongside @executor-js/sdk.

    Tool Path Mapping: Tools are accessed via <namespace>.<server-tool-name>. Server tool names (often snake_case) are preserved verbatim in the tool path.

    const executor = await createExecutor({
      setup: async (sdk) => {
        // Remote (SSE / HTTP)
        await sdk.sources.add({
          kind: "mcp",
          transport: "remote",
          endpoint: "https://mcp.example.com/sse",
          name: "docs",
        });
    
        // Stdio (local process)
        await sdk.sources.add({
          kind: "mcp",
          transport: "stdio",
          command: "npx",
          args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
          env: { LOG_LEVEL: "info" },
          cwd: "/work",
          name: "fs",
        });
      },
      onToolApproval: async (req) => {
        // Gate destructive tools
        if (req.toolPath.endsWith(".write_file")) {
          return { approved: false, reason: "writes need review" };
        }
        return { approved: true };
      },
      onElicitation: async (ctx) => {
        // Handle interactive flows (forms, OAuth)
        return { action: "decline" };
      },
    });
  6. Convert OpenAPI specs to tools

    main

    Convert an OpenAPI/Swagger specification into a set of Bash commands and a JavaScript API.

    Setup Requirements:

    • Install @executor-js/plugin-openapi alongside @executor-js/sdk.
    • The spec field must be a string (a URL, JSON text, or YAML text), not a parsed object.

    Configuration: Use sdk.sources.add inside the setup hook of createExecutor with kind: "openapi".

    Tool Mapping:

    • Tool Path: <name>.<firstUrlSegment>.<operationId>
    • Arguments: Path, query, and request body parameters are all flattened into a single object.
    • Bash Subcommand: Kebab-case of <firstUrlSegment>.<operationId> (e.g., pets.create-pet).

    Pitfalls:

    • Operations missing an operationId are skipped.
    • Name collisions between different parameter locations (path vs query) must be resolved by the user.
    import { createExecutor } from "@just-bash/executor";
    import { Bash } from "just-bash";
    
    const executor = await createExecutor({
      setup: async (sdk) => {
        await sdk.sources.add({
          kind: "openapi",
          spec: "https://api.example.com/openapi.json", // Must be a string
          endpoint: "https://api.example.com",
          name: "pets",
          headers: {
            Authorization: `Bearer ${process.env.API_TOKEN}`,
          },
        });
      },
      onToolApproval: "allow-all",
    });
    
    const bash = new Bash({
      customCommands: executor.commands,
      javascript: { invokeTool: executor.invokeTool },
    });
  7. Define inline tools with @just-bash/executor

    main

    Use inline tools when you want to expose specific JavaScript functions directly to the just-bash sandbox without an external API spec. You define a tools object where each key is a tool name and the value contains a description and an execute function.

    Conversion Rules:

    • JS API: await tools.namespace.action(args)
    • Bash CLI: namespace action --key value or namespace action --json '{"key": "value"}'
    • The first segment of the tool name acts as the namespace command.
    import { Bash } from "just-bash";
    import { createExecutor } from "@just-bash/executor";
    
    const executor = await createExecutor({
      tools: {
        "ns.action": {
          description: "What it does",
          execute: async (args: { /* shape */ }) => ({ /* JSON-serializable */ }),
        },
      },
    });
    
    const bash = new Bash({
      customCommands: executor.commands,
      javascript: { invokeTool: executor.invokeTool },
    });
  8. Lock fixtures for platform consistency

    main

    If you manually adjust a fixture to match a specific platform's behavior (e.g., forcing Linux-style output while recording on macOS), mark the fixture as "locked": true. This prevents RECORD_FIXTURES=1 from accidentally overwriting your manual adjustments.

    {
      "fixture_id": {
        "command": "ls -R",
        "files": { ... },
        "stdout": ".:\\ndir\\nfile.txt\\n...",
        "stderr": "",
        "exitCode": 0,
        "locked": true
      }
    }
  9. Convert GraphQL endpoints to tools

    main

    Convert a GraphQL endpoint into a set of Bash commands and a JavaScript API.

    Setup Requirements:

    • Install @executor-js/plugin-graphql alongside @executor-js/sdk.

    Configuration: Use sdk.sources.add inside the setup hook of createExecutor with kind: "graphql".

    Tool Mapping:

    • Queries: <name>.query.<fieldName>
    • Mutations: <name>.mutation.<fieldName>
    • Arguments: Matches the field's argument definitions.
    • Response Format: Returns the raw GraphQL envelope: { status, data, errors }. You must manually check errors and extract data in your scripts.

    Pitfalls:

    • Nested Objects: The plugin generates shallow selection sets. For queries returning nested objects, you must wrap the call in an inline tool that uses a manual fetch with a hand-written GraphQL query.
    • Validation: Required arguments (String!, ID!) must be provided; missing them will throw exceptions in JS scripts.
    • Subscriptions: Currently not supported as callable tools.
    import { createExecutor } from "@just-bash/executor";
    import { Bash } from "just-bash";
    
    const executor = await createExecutor({
      setup: async (sdk) => {
        await sdk.sources.add({
          kind: "graphql",
          endpoint: "https://api.github.com/graphql",
          name: "github",
          headers: {
            Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
          },
        });
      },
      onToolApproval: "allow-all",
    });
    
    // Example of reading a GraphQL response in a script
    const r = await tools.github.query.country({ code: "JP" });
    if (r.errors && r.errors.length) {
      throw new Error(r.errors.map((e) => e.message).join("; "));
    }
    const country = r.data.country;