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 / Feature | Status | Notes |
|---|---|---|
grep -rIlE (extended regex, recursive) | Works | Core of Gate F and audit scanning |
grep -ic, grep -q, grep -qF | Works | Used throughout gate-check |
sed, cat, head, tail, wc | Works | Text processing |
find, ls, mkdir, basename, dirname | Works | File operations |
date -u | Works | Timestamps for JSONL |
Pipes (|), redirections (>, >>), &&, || | Works | Shell plumbing |
Variable expansion, for loops, if/else | Works | Control flow |
echo, printf | Works | Output |
set -euo pipefail | Partial | set -e works, pipefail behavior may differ |
What does NOT work today
| Command / Feature | Status | Why |
|---|---|---|
python3 | Not available | WASM binary requires network download (designed for Vercel Sandbox cloud API) |
git | Not available | Not a built-in command |
shasum / sha256sum | Not available | Not a built-in command |
curl | Requires network opt-in | Disabled by default (correct for gate scripts, but means red-team runner cannot be sandboxed) |
source / . (sourcing scripts) | Partial | Works 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_chainedinlib/defaults.sh) - Path confinement (
require_inside_projectusesos.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-bash2. 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.mdExample 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.jsonl4. 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 === 1Limitations 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 sandboxjsonl_append/jsonl_append_chaineddo not work inside the sandboxrequire_inside_projectdoes 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-bashadds 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-bashinterface 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
- just-bash README
- Vercel Sandbox API docs
- speckit-security Security Model -- the built-in confinement and JSONL safety measures that work without any sandbox