Ag-Bash is an AI-native, sandboxed bash interpreter. This document describes its security model and the hardening levers available to embedders. It covers three layers:
- The primary architectural defense (no bash → JavaScript escape path).
- The secondary defense-in-depth layer (Node.js and browser).
- Supply-chain and runtime launch hardening for operators.
Defense-in-depth note: the secondary layers below exist to contain bugs in ag-bash itself or in host-supplied custom commands. They are not the primary control. The primary control is architectural: a bash script is lexed, parsed, and interpreted — there is no code path from a bash command to
eval/Function/ dynamicimport().
Please report security issues privately to the project maintainers rather than opening a public issue. Treat any leaked credential as compromised and rotate it immediately.
ag-bash never converts a bash script into JavaScript and never evaluates script
text as code. Commands are dispatched through a typed BashHost interface to
builtin implementations. This holds identically in Node.js and in the browser
bundle.
In Node.js builds, DefenseInDepthBox (src/security/defense-in-depth-box.ts)
monkey-patches dangerous globals (Function, eval, Module._load,
process.*, dynamic import() of data:/blob:/builtins, the
.constructor.constructor chain, well-known Symbols, Proxy.revocable, …)
only within the AsyncLocalStorage context of an active bash.exec(). It is
enabled by default in v5.0.0+. Concurrent, non-sandboxed code in the same
process is unaffected.
AsyncLocalStorage does not exist in browsers, so the entire
DefenseInDepthBox is a no-op in browser builds — the __BROWSER__ define
makes every guard early-return. As a result the browser bundle ships
Function/eval/Proxy fully live. The primary architectural defense still
holds (there is still no bash → JS path), but the secondary depth layer is
absent unless you opt in.
To restore Node-parity secondary depth in the browser, call
hardenBrowserGlobals() once, as early as possible (before any
bash.exec() and before any untrusted code runs):
import { Bash, hardenBrowserGlobals } from "@ag-bash/bash/browser";
// Freeze JS intrinsics (Object/Function/Array/... prototypes + constructors)
// so a future bug or a malicious host custom command cannot mutate the shared
// realm to mount prototype-pollution or intrinsic-hijacking.
hardenBrowserGlobals();
const bash = new Bash();Characteristics of hardenBrowserGlobals():
- Opt-in. It does not run automatically — freezing intrinsics is a one-way, realm-wide operation and can break hosts that lazily patch built-ins.
- Non-destructive. It only flips writability/extensibility via
Object.freeze; it never replaces or deletes any value, so no behavior is silently swapped. - Idempotent. Safe to call repeatedly; only the first call does work.
- Fail-open per intrinsic. A freeze that cannot be applied is reported in
the returned
failuresarray (and via the optionalonFailurecallback) rather than throwing — legitimate use is never broken. freezeGlobalThis: trueadditionally sealsglobalThis(strictest; can break polyfills that attach globals lazily).
Use isBrowserHardened() to check whether the pass has run.
SES follow-up. Full realm isolation via the
sespackage (lockdown()+ Compartments) is the gold standard but adds a runtime dependency and changes global semantics (tamingDate,Math,Error, etc.), which can break host code sharing the realm. Per the v6.0.0 risk posture we ship the lighter intrinsic-freeze and track SES integration as a documented follow-up.
hardenBrowserGlobals() closes the mutation surface; a strict CSP closes the
code-generation surface. Pair them. Serve the page hosting ag-bash with a CSP
that forbids inline and eval'd script:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
Notes:
- Do not add
'unsafe-eval'or'unsafe-inline'toscript-src. With'unsafe-eval'absent, the browser refuseseval/new Function/setTimeout-with-string-body at the engine level — a hard backstop for the liveFunction/evalthe browser bundle ships. - Use
object-src 'none'andbase-uri 'none'to block plugin and<base>hijack vectors. - If you must run ag-bash inside a worker, set the same CSP via the
Content-Security-Policyresponse header on the worker script.
When running the ag-bash CLI or the MCP server binary, launch Node with its own hardening flags so the host process is constrained even if a sandbox escape were attempted. Recommended invocation:
node \
--frozen-intrinsics \
--disallow-code-generation-from-strings \
--permission \
--allow-child-process=false \
./dist/bin/ag-bash.js| Flag | Effect |
|---|---|
--frozen-intrinsics |
Freezes all JS intrinsics at startup (the Node-native equivalent of hardenBrowserGlobals()), blocking prototype-pollution / intrinsic-hijack. |
--disallow-code-generation-from-strings |
Makes eval and new Function throw process-wide — a hard backstop matching the browser CSP script-src recommendation. |
--permission |
Enables the Node.js Permission Model (deny-by-default for fs / child_process / worker / addons unless explicitly granted). |
--allow-child-process=false |
Explicitly denies spawning child processes (child_process), neutralizing the most valuable escape target. Add --allow-fs-read=/--allow-fs-write= to grant only the directories the workload needs. |
Caveats:
--frozen-intrinsicsis marked experimental in some Node versions and can conflict with libraries that patch built-ins at import time. Validate against your dependency set before enabling in production.- The Permission Model intercepts
fs,child_process,worker_threads, and native addons; grant the minimum the workload needs via--allow-fs-read/--allow-fs-writeallowlists rather than enabling broadly.- These flags must be passed to the
nodebinary directly (not via npm scripts that re-exec), or set throughNODE_OPTIONSwhere the flag is permitted there.
- Audit gates the build. CI runs
pnpm audit --audit-level=highwith nocontinue-on-error; a high/critical advisory fails theQualityworkflow. When a transitive advisory lands, pin the patched version via thepnpm.overridesblock in the rootpackage.json(see thefast-xml-builder/sanitize-htmlentries) rather than suppressing the gate. - Provenance on publish. The
Publishworkflow publishes withnpm publish --provenanceusing GitHub OIDC (id-token: write), producing a Sigstore attestation that links each published tarball to the exact workflow, commit, and repository that built it. Consumers can verify withnpm audit signatures. - Signature verification. The publish pipeline runs
npm audit signaturesto verify registry signatures of installed dependencies before building. - Integrity-locked installs. All CI installs use
pnpm install --frozen-lockfile, so the resolved dependency graph and its integrity hashes cannot drift between lockfile and install.
- Browser: call
hardenBrowserGlobals()once at startup. - Browser: serve under a strict CSP without
'unsafe-eval'/'unsafe-inline'. - Node CLI/MCP: launch with
--frozen-intrinsics --disallow-code-generation-from-strings --permission --allow-child-process=false. - Keep
defenseInDepthenabled (the v5.0.0+ default) forbash.exec(). - Grant filesystem/network access only through ag-bash's VFS and network allow-list, never the host's ambient authority.