Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 77 additions & 87 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Homepage: [bashkit.sh](https://bashkit.sh)

- **Secure by default** - No process spawning, no filesystem access, no network access unless explicitly enabled. [250+ threats](specs/threat-model.md) analyzed and mitigated
- **POSIX compliant** - Substantial IEEE 1003.1-2024 Shell Command Language compliance
- **Sandboxed, in-process execution** - All 160 commands reimplemented in Rust, no `fork`/`exec`
- **Sandboxed, in-process execution** - All 156 commands reimplemented in Rust, no `fork`/`exec`
- **Virtual filesystem** - InMemoryFs, OverlayFs, MountableFs with optional RealFs backend (`realfs` feature)
- **Resource limits** - Command count, loop iterations, function depth, output size, filesystem size, parser fuel
- **Network allowlist** - HTTP access denied by default, per-domain control
Expand Down Expand Up @@ -113,7 +113,7 @@ assert_eq!(output.result["stdout"], "hello\nworld\n");
</a>
</div>

## Built-in Commands (160)
## Built-in Commands (156)

| Category | Commands |
|----------|----------|
Expand Down Expand Up @@ -468,7 +468,7 @@ Bashkit is built for running untrusted scripts from AI agents and users. Securit

| Layer | Protection |
|-------|------------|
| **No process spawning** | All 160 commands are reimplemented in Rust — no `fork`, `exec`, or shell escape |
| **No process spawning** | All 156 commands are reimplemented in Rust — no `fork`, `exec`, or shell escape |
| **Virtual filesystem** | Scripts see an in-memory FS by default; no host filesystem access unless explicitly mounted |
| **Network allowlist** | HTTP access is denied by default; each domain must be explicitly allowed |
| **Resource limits** | Configurable caps on commands (10K), loop iterations (100K), function depth (100), output (10MB), input (10MB) |
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Homepage: [bashkit.sh](https://bashkit.sh)

- Sandboxed execution in-process, without containers or subprocess orchestration
- Full bash syntax: variables, pipelines, redirects, loops, functions, and arrays
- 160 built-in commands including `grep`, `sed`, `awk`, `jq`, `curl`, and `find`
- 156 built-in commands including `grep`, `sed`, `awk`, `jq`, `curl`, and `find`
- Persistent interpreter state across calls, including variables, cwd, and VFS contents
- Direct virtual filesystem APIs, constructor mounts, and live host mounts
- Snapshot and restore support on `Bash` and `BashTool`
Expand Down
17 changes: 5 additions & 12 deletions crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,11 @@ for sandbox security reasons. See the compliance spec for details.

## Quick Status

| Category | Count |
|----------|-------|
| Core & Navigation | 12 |
| Flow Control & Variables | 23 |
| Shell | 7 |
| Text Processing | 20 |
| File Operations & Inspection | 17 |
| Archives & Byte Tools | 6 |
| Utilities & System | 20 |
| Network | 2 |
| Experimental | 3 |
| **Total** | **150** |
| Group | Count |
|-------|-------|
| Always-on builtins | 142 |
| Feature-gated builtins (`jq`, `git`, `ssh`/`scp`/`sftp`, `python`/`python3`, `sqlite`/`sqlite3`, `ts`/`typescript`/`node`/`deno`/`bun`) | 14 |
| **Total** | **156** |

---

Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ Scripts may attempt to leak sensitive information.
| IP address (TM-INF-007) | `ip addr`, `ifconfig` | Not implemented | MITIGATED |
| System info (TM-INF-008) | `uname -a` | Returns configurable virtual values | MITIGATED |
| User ID (TM-INF-009) | `id` | Returns hardcoded uid=1000 | MITIGATED |
| Date/time (TM-INF-018) | `date` | Returns real host time (fingerprinting risk) | **OPEN** |
| Date/time (TM-INF-018) | `date` | Returns real host time (fingerprinting risk) | **MITIGATED** (opt-in: `Bash::builder().fixed_epoch` / `.epoch_offset`) |

**Network Exfiltration:**

Expand Down
82 changes: 79 additions & 3 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,53 @@ use crate::interpreter::ExecResult;
/// %n Newline
/// %t Tab
/// %% Literal %
/// THREAT[TM-INF-018]: Supports a fixed epoch to prevent leaking real host time.
/// THREAT[TM-INF-018]: Supports a fixed epoch OR a constant offset on
/// the real clock so callers can blind absolute wall-clock time without
/// breaking elapsed-time logic. The two modes are mutually exclusive
/// — `fixed_epoch` wins if both are set.
pub struct Date {
/// Fixed UTC epoch for virtualized time. None = use real system clock.
fixed_epoch: Option<DateTime<Utc>>,
/// Constant offset applied to `Utc::now()` when `fixed_epoch` is None.
offset_seconds: i64,
}

impl Date {
pub fn new() -> Self {
Self { fixed_epoch: None }
Self {
fixed_epoch: None,
offset_seconds: 0,
}
}

/// Create a Date builtin with a fixed epoch (for sandboxing).
pub fn with_fixed_epoch(epoch: DateTime<Utc>) -> Self {
Self {
fixed_epoch: Some(epoch),
offset_seconds: 0,
}
}

/// Create a Date builtin that offsets the real clock by the given
/// number of seconds. Useful when scripts need a ticking clock but
/// must not observe the host's exact wall-clock time.
pub fn with_offset_seconds(offset: i64) -> Self {
Self {
fixed_epoch: None,
offset_seconds: offset,
}
}

fn now(&self) -> DateTime<Utc> {
self.fixed_epoch.unwrap_or_else(Utc::now)
if let Some(t) = self.fixed_epoch {
return t;
}
if self.offset_seconds == 0 {
return Utc::now();
}
Utc::now()
.checked_add_signed(chrono::Duration::seconds(self.offset_seconds))
.unwrap_or_else(Utc::now)
}
}

Expand Down Expand Up @@ -535,6 +562,55 @@ mod tests {
assert!(result.stdout.len() > 10);
}

/// TM-INF-018: fixed_epoch wins over real clock.
#[test]
fn date_fixed_epoch_returns_fixed_time() {
let fixed = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let d = Date::with_fixed_epoch(fixed);
assert_eq!(d.now(), fixed);
}

/// TM-INF-018: non-zero offset shifts the real clock without
/// freezing it. Verify the offset is applied within a sub-second
/// tolerance vs `Utc::now() + offset`.
#[test]
fn date_offset_seconds_shifts_real_clock() {
let offset: i64 = 365 * 24 * 60 * 60; // +1 year
let d = Date::with_offset_seconds(offset);
let before = Utc::now();
let observed = d.now();
let after = Utc::now();
let expected_low = before + chrono::Duration::seconds(offset);
let expected_high = after + chrono::Duration::seconds(offset);
assert!(
observed >= expected_low && observed <= expected_high,
"offset clock {observed} not in [{expected_low}, {expected_high}]"
);
}

/// TM-INF-018: fixed_epoch takes priority if both modes are set
/// (defensive — the builder enforces exclusivity but the struct
/// fields are pub(crate)-ish and could be combined directly).
#[test]
fn date_fixed_epoch_overrides_offset() {
let fixed = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let d = Date {
fixed_epoch: Some(fixed),
offset_seconds: 99_999,
};
assert_eq!(d.now(), fixed);
}

/// TM-INF-018: zero offset = real clock (no allocation overhead path).
#[test]
fn date_zero_offset_uses_real_clock() {
let d = Date::with_offset_seconds(0);
let before = Utc::now();
let observed = d.now();
let after = Utc::now();
assert!(observed >= before && observed <= after);
}

#[tokio::test]
async fn test_date_format_year() {
let result = run_date(&["+%Y"]).await;
Expand Down
16 changes: 14 additions & 2 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,15 @@ impl Interpreter {

/// Create a new interpreter with the given filesystem.
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self::with_config(fs, None, None, None, HashMap::new(), ShellProfile::Full)
Self::with_config(
fs,
None,
None,
None,
None,
HashMap::new(),
ShellProfile::Full,
)
}

/// Create a new interpreter with custom username, hostname, and builtins.
Expand All @@ -894,6 +902,7 @@ impl Interpreter {
username: Option<String>,
hostname: Option<String>,
fixed_epoch: Option<i64>,
epoch_offset: Option<i64>,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
shell_profile: ShellProfile,
) -> Self {
Expand Down Expand Up @@ -1076,14 +1085,17 @@ impl Interpreter {
);
builtins.insert(".".to_string(), Box::new(builtins::Source::new(fs.clone())));

// THREAT[TM-INF-018]: Use fixed epoch if configured, else real clock
// THREAT[TM-INF-018]: Resolve the virtual clock mode for `date`.
// Priority: fixed_epoch > epoch_offset > real clock.
builtins.insert(
"date".to_string(),
Box::new(if let Some(epoch) = fixed_epoch {
use chrono::DateTime;
builtins::Date::with_fixed_epoch(
DateTime::from_timestamp(epoch, 0).unwrap_or_default(),
)
} else if let Some(offset) = epoch_offset {
builtins::Date::with_offset_seconds(offset)
} else {
builtins::Date::new()
}),
Expand Down
25 changes: 24 additions & 1 deletion crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//! - **Experimental: Python** - Embedded Python via [Monty](https://github.com/pydantic/monty) (`python` feature)
//! - **Experimental: SQLite** - Embedded SQLite-compatible engine via [Turso](https://github.com/tursodatabase/turso) (`sqlite` feature)
//!
//! # Built-in Commands (160)
//! # Built-in Commands (156)
//!
//! | Category | Commands |
//! |----------|----------|
Expand Down Expand Up @@ -1209,6 +1209,8 @@ pub struct BashBuilder {
hostname: Option<String>,
/// Fixed epoch for virtualizing the `date` builtin (TM-INF-018)
fixed_epoch: Option<i64>,
/// Constant seconds offset applied to real-clock for `date` (TM-INF-018)
epoch_offset: Option<i64>,
shell_profile: interpreter::ShellProfile,
custom_builtins: HashMap<String, Box<dyn Builtin>>,
/// Files to mount in the virtual filesystem
Expand Down Expand Up @@ -1397,6 +1399,24 @@ impl BashBuilder {
/// When set, `date` returns this fixed time instead of the real clock.
pub fn fixed_epoch(mut self, epoch: i64) -> Self {
self.fixed_epoch = Some(epoch);
self.epoch_offset = None;
self
}

/// Apply a constant offset (in seconds) to the real system clock for
/// the `date` builtin. Use this when scripts need time to advance at
/// real-clock rate but you want to obscure the absolute wall-clock
/// time from the sandbox (timing-correlation resistance).
///
/// THREAT[TM-INF-018]: A non-zero offset prevents `date` from
/// exposing the host's exact wall-clock time while still letting
/// time-sensitive scripts observe elapsed-time deltas.
///
/// `fixed_epoch` and `epoch_offset` are mutually exclusive — the
/// last builder call wins.
pub fn epoch_offset(mut self, seconds: i64) -> Self {
self.epoch_offset = Some(seconds);
self.fixed_epoch = None;
self
}

Expand Down Expand Up @@ -2430,6 +2450,7 @@ impl BashBuilder {
self.username,
self.hostname,
self.fixed_epoch,
self.epoch_offset,
self.cwd,
self.shell_profile,
self.limits,
Expand Down Expand Up @@ -2681,6 +2702,7 @@ impl BashBuilder {
username: Option<String>,
hostname: Option<String>,
fixed_epoch: Option<i64>,
epoch_offset: Option<i64>,
cwd: Option<PathBuf>,
shell_profile: interpreter::ShellProfile,
limits: ExecutionLimits,
Expand Down Expand Up @@ -2714,6 +2736,7 @@ impl BashBuilder {
username.clone(),
hostname,
fixed_epoch,
epoch_offset,
custom_builtins,
shell_profile,
);
Expand Down
67 changes: 67 additions & 0 deletions crates/bashkit/tests/threat_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3772,6 +3772,73 @@ echo "done"
}
}

// =============================================================================
// DATE / VIRTUAL CLOCK TESTS (TM-INF-018)
// =============================================================================

mod tm_inf_018_date {
use super::*;

/// TM-INF-018: `Bash::builder().fixed_epoch(N)` causes `date +%s`
/// to return exactly N, regardless of host wall-clock.
#[tokio::test]
async fn fixed_epoch_freezes_date() {
let mut bash = Bash::builder().fixed_epoch(1_700_000_000).build();
let r = bash.exec("date +%s").await.unwrap();
assert_eq!(r.exit_code, 0);
assert_eq!(r.stdout.trim(), "1700000000");
}

/// TM-INF-018: `Bash::builder().epoch_offset(N)` keeps the clock
/// ticking but shifts its absolute value by N seconds. Verify two
/// consecutive reads differ by less than 1s (ticking) yet sit at
/// least N-1 seconds ahead of host real time (offset applied).
#[tokio::test]
async fn epoch_offset_shifts_real_clock() {
let offset = 365_i64 * 24 * 3600; // +1 year
let host_before = chrono::Utc::now().timestamp();
let mut bash = Bash::builder().epoch_offset(offset).build();
let r = bash.exec("date +%s").await.unwrap();
assert_eq!(r.exit_code, 0);
let observed: i64 = r.stdout.trim().parse().unwrap();
// observed should be ~ host_before + offset
let delta = observed - (host_before + offset);
assert!(
(-2..=2).contains(&delta),
"offset clock drifted: observed={observed}, expected≈{}, delta={delta}",
host_before + offset
);
}

/// TM-INF-018: `fixed_epoch` and `epoch_offset` are mutually
/// exclusive — last builder call wins. fixed_epoch followed by
/// epoch_offset should disable fixed_epoch.
#[tokio::test]
async fn last_builder_call_wins_offset_after_fixed() {
let mut bash = Bash::builder().fixed_epoch(0).epoch_offset(0).build();
let r = bash.exec("date +%s").await.unwrap();
let observed: i64 = r.stdout.trim().parse().unwrap();
// Should be near real-time (within a few seconds), not 0.
let now = chrono::Utc::now().timestamp();
assert!(
(observed - now).abs() < 60,
"epoch_offset(0) did not override fixed_epoch(0): observed={observed}, real={now}"
);
}

/// TM-INF-018: the inverse — epoch_offset then fixed_epoch should
/// disable the offset.
#[tokio::test]
async fn last_builder_call_wins_fixed_after_offset() {
let mut bash = Bash::builder()
.epoch_offset(99_999)
.fixed_epoch(1_700_000_000)
.build();
let r = bash.exec("date +%s").await.unwrap();
assert_eq!(r.stdout.trim(), "1700000000");
}
}

// =============================================================================
// TRACE EVENT TESTS (TM-INF-019)
// =============================================================================
Expand Down
13 changes: 9 additions & 4 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -184,16 +184,21 @@ eval-scripting-save dataset="crates/bashkit-eval/data/scripting-tool/many-tools.

# === Security ===

# Auto-install cargo-vet if missing (idempotent, matches CI's
# taiki-e/install-action step). Internal helper for vet recipes.
_ensure-vet:
@command -v cargo-vet >/dev/null 2>&1 || cargo install cargo-vet --locked

# Run supply chain audit (cargo-vet)
vet:
cargo vet
vet: _ensure-vet
cargo vet --locked

# Suggest crates to audit
vet-suggest:
vet-suggest: _ensure-vet
cargo vet suggest

# Certify a crate after audit
vet-certify crate version:
vet-certify crate version: _ensure-vet
cargo vet certify {{crate}} {{version}}

# === Nightly CI ===
Expand Down
Loading
Loading