speckit-security
Sandbox (Experimental)

just-bash

Run speckit-security gate scripts inside Vercel Labs' just-bash virtual bash environment for filesystem and network isolation.

Experimental. This integration is exploratory. It works for a subset of the gate scripts today and has known limitations. Do not depend on it in production without testing your specific workflow end-to-end.

What is just-bash?

just-bash is a virtual bash environment by Vercel Labs. It runs ~80 Unix commands (grep, sed, cat, find, awk, jq, etc.) in a TypeScript process with an in-memory filesystem. Network access is disabled by default.

It is not a full Linux VM. It's a purpose-built shell emulator designed for AI agents that need a sandboxed execution environment.

Repository: github.com/vercel-labs/just-bash License: Apache-2.0

Why use it with speckit-security?

speckit-security scripts normally run directly on the host shell. The built-in project-root confinement validates file paths, but it's a check, not a boundary. just-bash adds a real isolation layer:

  • Filesystem isolation -- scripts only see files you explicitly load. No /etc, no ~/.ssh, no sibling repos.
  • Network disabled -- gate scripts cannot make HTTP calls (the red-team runner is excluded from sandbox use for this reason).
  • Execution limits -- configurable caps on loop iterations, call depth, and total command count.
  • Write isolation -- the in-memory filesystem means writes never touch the real project.

Important: this adds a dependency

speckit-security's core scripts have zero runtime dependencies beyond bash, grep, sed, git, and python3 -- all standard OS tools.

Adding just-bash introduces a Node.js / npm dependency. This is an opt-in choice for teams that want sandbox isolation. The core scripts continue to work without it. The sandbox wrapper is a separate entrypoint, not a replacement for the standard scripts.

# Standard (no deps):        bash scripts/bash/gate-check.sh <spec>
# Sandboxed (requires npm):  node sandbox/run-gate-check.mjs <spec>

What works today

Tested against just-bash@latest in Node.js:

Command / FeatureStatusNotes
grep -rIlE (extended regex, recursive)WorksCore of Gate F and audit scanning
grep -ic, grep -q, grep -qFWorksUsed throughout gate-check
sed, cat, head, tail, wcWorksText processing
find, ls, mkdir, basename, dirnameWorksFile operations
date -uWorksTimestamps for JSONL
Pipes (|), redirections (>, >>), &&, ||WorksShell plumbing
Variable expansion, for loops, if/elseWorksControl flow
echo, printfWorksOutput
set -euo pipefailPartialset -e works, pipefail behavior may differ

What does NOT work today

Command / FeatureStatusWhy
python3Not availableWASM binary requires network download (designed for Vercel Sandbox cloud API)
gitNot availableNot a built-in command
shasum / sha256sumNot availableNot a built-in command
curlRequires network opt-inDisabled by default (correct for gate scripts, but means red-team runner cannot be sandboxed)
source / . (sourcing scripts)PartialWorks for simple scripts, complex sourcing chains may need adjustment

Impact on speckit-security scripts

The scripts depend on python3 for:

  • YAML config parsing (lib/config.sh)
  • JSONL output (jsonl_append / jsonl_append_chained in lib/defaults.sh)
  • Path confinement (require_inside_project uses os.path.realpath)
  • Red-team scenario parsing (red-team-run.sh)

Without Python, the full gate-check and audit scripts cannot run inside just-bash unmodified. The approach below works around this by pre-parsing config and writing JSONL from the Node.js wrapper.

Step-by-step: sandboxed gate-check

This walkthrough creates a Node.js wrapper that loads your project files into just-bash's in-memory filesystem and runs a simplified gate-check. It handles config parsing and JSONL output in JavaScript instead of Python.

1. Install just-bash

cd your-project
npm install just-bash

2. Create the sandbox wrapper

Create sandbox/run-gate-check.mjs:

#!/usr/bin/env node
// Sandboxed gate-check runner using just-bash
// Usage: node sandbox/run-gate-check.mjs <spec-path>

import { Bash } from "just-bash";
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
import { join, resolve, relative } from "path";
import { createHash } from "crypto";
import { appendFileSync, mkdirSync } from "fs";

const specPath = process.argv[2];
if (!specPath) {
  console.error("usage: node sandbox/run-gate-check.mjs <spec-path>");
  process.exit(2);
}

// --- Load project files into the in-memory filesystem ---------------

const projectRoot = process.cwd();
const files = {};

function loadDir(dir, prefix) {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    const vpath = prefix + "/" + entry.name;
    if (entry.isDirectory()) {
      const skip = [
        "node_modules", ".git", ".next", "out",
        ".wrangler", "dist", ".source",
      ];
      if (skip.includes(entry.name)) continue;
      loadDir(full, vpath);
    } else if (entry.isFile()) {
      try {
        files[vpath] = readFileSync(full, "utf8");
      } catch {
        // skip binary files
      }
    }
  }
}

loadDir(projectRoot, "");
console.log(`Loaded ${Object.keys(files).length} files into sandbox`);

// --- Pre-parse config (replaces python3 config.sh) ------------------

const configPath = ".specify/extensions/tekimax-security/tekimax-security-config.yml";
let userSecretPatterns = [];
let userInlinePatterns = [];
let userGatewayAllowlist = [];

if (files["/" + configPath]) {
  // Minimal YAML list parser for the specific keys we need
  const content = files["/" + configPath];
  const parseList = (key) => {
    const re = new RegExp(
      `${key}:\\s*\\n((?:\\s+-\\s+.+\\n?)*)`,
    );
    const m = content.match(re);
    if (!m) return [];
    return m[1]
      .split("\n")
      .map((l) => l.replace(/^\s*-\s*/, "").replace(/["']/g, "").trim())
      .filter(Boolean);
  };
  userSecretPatterns = parseList("secret_patterns");
  userInlinePatterns = parseList("inline_prompt_patterns");
  userGatewayAllowlist = parseList("stack_direct_sdk");
}

// --- Inject parsed config as env vars for the bash script -----------

const bash = new Bash({
  files,
  cwd: "/",
  env: {
    HOME: "/",
    SANDBOX: "true",
    // Pass parsed config as env vars so the script doesn't need python3
    USER_SECRET_PATTERNS: userSecretPatterns.join("|"),
    USER_INLINE_PATTERNS: userInlinePatterns.join("|"),
    USER_GATEWAY_ALLOWLIST: userGatewayAllowlist.join(":"),
  },
  executionLimits: {
    maxCallDepth: 50,
    maxCommandCount: 10000,
    maxLoopIterations: 10000,
  },
});

// --- Run a simplified gate-check in the sandbox ---------------------

const specVpath = "/" + relative(projectRoot, resolve(specPath));

// Gate F: inline prompt scan (the most valuable sandbox check)
const inlinePromptRe =
  '([Yy]ou[[:space:]]+are[[:space:]]+(a|an)[[:space:]]+(helpful|AI|assistant|chatbot|expert)|<\\|system\\|>|<\\|im_start\\|>system)';

const gateF = await bash.exec(
  `if [ -d /src ]; then
    hits=$(grep -rIliE '${inlinePromptRe}' --include="*.ts" --include="*.tsx" --include="*.js" --include="*.py" /src 2>/dev/null || true)
    if [ -n "$hits" ]; then
      echo "FAIL: inline prompts found"
      echo "$hits"
      exit 1
    fi
  fi
  echo "PASS"`,
);

// Gate A: data contract section
const gateA = await bash.exec(
  `grep -qF "## 2. Data Contract" ${specVpath} 2>/dev/null || grep -qF "## Data Contract" ${specVpath} 2>/dev/null; echo $?`,
);

// Gate B: threat model with content
const gateB = await bash.exec(
  `if grep -qF "## Security / Threat Model" ${specVpath} 2>/dev/null || grep -qF "## Security" ${specVpath} 2>/dev/null; then
    if grep -qE '^\\|[[:space:]]*T[0-9]' ${specVpath} 2>/dev/null || grep -qE '^\\|[[:space:]]*(Spoofing|Tampering)' ${specVpath} 2>/dev/null; then
      echo "PASS"
    else
      echo "FAIL: STRIDE table has no threat rows"
    fi
  else
    echo "FAIL: missing threat model"
  fi`,
);

// --- Report ---------------------------------------------------------

console.log("\n--- Sandboxed Gate Check ---");
console.log(`Spec: ${specPath}`);
console.log(`Gate A (Data Contract): ${gateA.stdout.trim() === "0" ? "PASS" : "FAIL"}`);
console.log(`Gate B (Threat Model):  ${gateB.stdout.trim()}`);
console.log(`Gate F (Inline Scan):   ${gateF.stdout.trim().split("\n")[0]}`);

if (gateF.stdout.includes("FAIL")) {
  console.log("\nInline prompt findings:");
  console.log(
    gateF.stdout
      .split("\n")
      .slice(1)
      .map((l) => "  " + l)
      .join("\n"),
  );
}

// --- Write JSONL to real filesystem (outside sandbox) ---------------

const logDir = join(projectRoot, ".tekimax-security");
mkdirSync(logDir, { recursive: true });
const logPath = join(logDir, "gate-log.jsonl");

// Read previous hash for chain
let prevHash = "genesis";
if (existsSync(logPath)) {
  const lines = readFileSync(logPath, "utf8").trim().split("\n");
  if (lines.length > 0 && lines[lines.length - 1]) {
    prevHash =
      "sha256:" +
      createHash("sha256")
        .update(lines[lines.length - 1])
        .digest("hex");
  }
}

const verdict =
  gateA.stdout.trim() === "0" &&
  gateB.stdout.trim() === "PASS" &&
  gateF.stdout.trim() === "PASS"
    ? "PASS"
    : "BLOCK";

const entry = JSON.stringify({
  spec: specPath,
  phase: "before_implement",
  verdict,
  ts: new Date().toISOString(),
  user: "sandbox",
  sandbox: "just-bash",
  gates: {
    A: gateA.stdout.trim() === "0" ? "pass" : "fail",
    B: gateB.stdout.trim().startsWith("PASS") ? "pass" : "fail",
    F: gateF.stdout.trim() === "PASS" ? "pass" : "fail",
  },
  prev_hash: prevHash,
});

appendFileSync(logPath, entry + "\n");

console.log(`\nVERDICT: ${verdict}`);
console.log(`Gate log: ${logPath}`);

process.exit(verdict === "PASS" ? 0 : 1);

3. Run it

node sandbox/run-gate-check.mjs .specify/specs/F-001-my-feature.md

Example output:

Loaded 132 files into sandbox

--- Sandboxed Gate Check ---
Spec: .specify/specs/F-001-my-feature.md
Gate A (Data Contract): PASS
Gate B (Threat Model):  PASS
Gate F (Inline Scan):   PASS

VERDICT: PASS
Gate log: .tekimax-security/gate-log.jsonl

4. Verify isolation

The sandbox prevents access to anything outside the loaded files:

// This returns exit 1 — /etc doesn't exist in the sandbox
const r = await bash.exec("cat /etc/passwd");
// r.exitCode === 1
// r.stderr === "cat: /etc/passwd: No such file or directory"

// Network is disabled — curl fails
const r2 = await bash.exec("curl https://example.com");
// r2.exitCode === 1

Limitations and caveats

Gates C, D, E are not covered

The wrapper above demonstrates Gates A, B, and F. Gates C (Model Governance), D (Guardrails), and E (Red Team) require more complex file parsing. You can extend the wrapper to add them -- the pattern is the same: run grep/sed commands inside the sandbox and parse the results in JavaScript.

No Python in the sandbox

The python3 runtime in just-bash downloads a CPython WASM binary over the network. It's designed for the Vercel Sandbox API cloud environment, not local Node.js. This means:

  • lib/config.sh (YAML parser) does not work inside the sandbox
  • jsonl_append / jsonl_append_chained do not work inside the sandbox
  • require_inside_project does not work inside the sandbox (but isn't needed -- the in-memory filesystem is the confinement)

The wrapper works around this by doing config parsing and JSONL output in JavaScript instead.

No git

git is not a built-in command. Checks that use git ls-files (e.g. .env committed detection) or git config user.name need to be handled by the Node.js wrapper.

Red-team runner cannot be sandboxed

red-team-run.sh makes HTTP requests to a staging endpoint by design. Sandboxing it with network disabled would defeat its purpose. Continue running it directly on the host with the built-in never_run_against safety guards.

Not a full POSIX shell

just-bash implements ~80 commands, not the full POSIX spec. Some edge cases in complex grep flag combinations, sed expressions, or shell syntax may behave differently. Always test your specific scripts.

Future directions

  • Python support -- if just-bash adds offline CPython WASM loading, the full gate scripts could run unmodified.
  • Vercel Sandbox API -- for CI or cloud use cases, the Vercel Sandbox API provides the same just-bash interface with full Python support. This would allow running the unmodified scripts in a cloud sandbox.
  • Full gate coverage -- extend the wrapper to cover all six gates with JavaScript equivalents of the Python helpers.
  • Pre-built sandbox image -- package a ready-to-use sandbox wrapper as an npm package that users can install alongside speckit-security.

Further reading

On this page