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
40 changes: 24 additions & 16 deletions crates/bashkit-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,12 @@ in flight, so the underlying `Promise<string>` callback could never run.
## Snapshot / Restore

State snapshots are available on both `Bash` and `BashTool` instances.
The default snapshot methods use an unkeyed checksum for accidental-corruption
checks only; use the `*Keyed` methods with a secret key for snapshots that cross
trust boundaries such as user uploads, shared storage, or network transport.

Security: unkeyed `Bash` snapshots use a public corruption-detection digest and
are forgeable. Use `hmacKey` whenever snapshot bytes cross a trust boundary
(network, user uploads, shared storage). `BashTool` snapshots require `hmacKey`
because they include tool session state, VFS contents, and counters that may be
restored in multi-tenant agent services.

```typescript
import { Bash, BashTool } from "@everruns/bashkit";
Expand Down Expand Up @@ -392,15 +395,20 @@ console.log(restored.executeSync("pwd").stdout); // /workspace\n
const tool = new BashTool({ username: "agent", maxCommands: 5 });
tool.executeSync("export TOOL_STATE=ready");

const toolSnapshot = tool.snapshot();
const toolShellOnly = tool.snapshot({ excludeFilesystem: true });
const restoredTool = BashTool.fromSnapshot(toolSnapshot, {
username: "agent",
maxCommands: 5,
});
const hmacKey = new TextEncoder().encode(process.env.SNAPSHOT_SECRET!);
const toolSnapshot = tool.snapshot({ hmacKey });
const toolShellOnly = tool.snapshot({ excludeFilesystem: true, hmacKey });
const restoredTool = BashTool.fromSnapshot(
toolSnapshot,
{
username: "agent",
maxCommands: 5,
},
{ hmacKey },
);

console.log(restoredTool.executeSync("echo $TOOL_STATE").stdout); // ready\n
restoredTool.restoreSnapshot(toolShellOnly);
restoredTool.restoreSnapshot(toolShellOnly, { hmacKey });
```

## Framework Integrations
Expand Down Expand Up @@ -452,18 +460,18 @@ import {
- `clearCancel()`
- `reset()`
- `addBuiltin(name, callback)` / `removeBuiltin(name)` — register/unregister persistent JS builtins
- `snapshot()` / `snapshotKeyed(key, options?)`
- `restoreSnapshot(data)` / `restoreSnapshotKeyed(data, key)`
- `Bash.fromSnapshot(data)` / `Bash.fromSnapshotKeyed(data, key, options?)`
- `snapshot(options?)` / `snapshotKeyed(key, options?)`
- `restoreSnapshot(data, options?)` / `restoreSnapshotKeyed(data, key)`
- `Bash.fromSnapshot(data, options?)` / `Bash.fromSnapshotKeyed(data, key)`
- Direct VFS helpers: `readFile`, `writeFile`, `appendFile`, `mkdir`, `remove`, `exists`, `stat`, `readDir`, `ls`, `glob`, `mount`, `unmount`, `fs`

### BashTool

- All execution, cancellation (`cancel()`, `clearCancel()`), reset, custom builtins, snapshot, restore, and direct VFS helpers from `Bash`
- Tool metadata: `name`, `version`, `shortDescription`
- `snapshot()` / `snapshotKeyed(key, options?)`
- `restoreSnapshot(data)` / `restoreSnapshotKeyed(data, key)`
- `BashTool.fromSnapshot(data, options?)` / `BashTool.fromSnapshotKeyed(data, key, options?)`
- `snapshot({ hmacKey, ...options })`
- `restoreSnapshot(data, { hmacKey })`
- `BashTool.fromSnapshot(data, options?, { hmacKey })`
- `description()`
- `help()`
- `systemPrompt()`
Expand Down
90 changes: 76 additions & 14 deletions crates/bashkit-js/__test__/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ test("integration: BashTool reset clears state", (t) => {
t.is(tool.executeSync("whoami").stdout.trim(), "testuser");
});

const snapshotKey = new TextEncoder().encode("integration snapshot hmac key");

Comment on lines +272 to +273
test("integration: BashTool snapshot roundtrip preserves state and config", (t) => {
const tool = new BashTool({
username: "agent",
Expand All @@ -279,12 +281,16 @@ test("integration: BashTool snapshot roundtrip preserves state and config", (t)
"export BUILD_ID=42; mkdir -p /workspace && cd /workspace && echo ready > state.txt",
);

const snapshot = tool.snapshot();
const restored = BashTool.fromSnapshot(snapshot, {
username: "agent",
maxCommands: 5,
maxLoopIterations: 50,
});
const snapshot = tool.snapshot({ hmacKey: snapshotKey });
const restored = BashTool.fromSnapshot(
snapshot,
{
username: "agent",
maxCommands: 5,
maxLoopIterations: 50,
},
{ hmacKey: snapshotKey },
);

t.is(restored.executeSync("echo $BUILD_ID").stdout.trim(), "42");
t.is(restored.executeSync("cat /workspace/state.txt").stdout.trim(), "ready");
Expand All @@ -302,12 +308,12 @@ test("integration: BashTool restoreSnapshot after reset restores original state"
const tool = new BashTool({ username: "agent" });
tool.executeSync("export SNAP=yes; mkdir -p /tmp/restore && cd /tmp/restore");

const snapshot = tool.snapshot();
const snapshot = tool.snapshot({ hmacKey: snapshotKey });

tool.reset();
t.is(tool.executeSync("echo ${SNAP:-missing}").stdout.trim(), "missing");

tool.restoreSnapshot(snapshot);
tool.restoreSnapshot(snapshot, { hmacKey: snapshotKey });
t.is(tool.executeSync("echo $SNAP").stdout.trim(), "yes");
t.is(tool.executeSync("pwd").stdout.trim(), "/tmp/restore");
t.is(tool.executeSync("whoami").stdout.trim(), "agent");
Expand All @@ -330,10 +336,13 @@ test("integration: BashTool snapshot can exclude filesystem", (t) => {
const tool = new BashTool();
tool.executeSync("export KEEP=1; echo saved > /tmp/tool.txt");

const snapshot = tool.snapshot({ excludeFilesystem: true });
const snapshot = tool.snapshot({
excludeFilesystem: true,
hmacKey: snapshotKey,
});

tool.executeSync("export KEEP=2; echo changed > /tmp/tool.txt");
tool.restoreSnapshot(snapshot);
tool.restoreSnapshot(snapshot, { hmacKey: snapshotKey });

t.is(tool.executeSync("echo $KEEP").stdout.trim(), "1");
t.is(tool.executeSync("cat /tmp/tool.txt").stdout.trim(), "changed");
Expand All @@ -342,8 +351,10 @@ test("integration: BashTool snapshot can exclude filesystem", (t) => {
test("integration: BashTool empty snapshot roundtrip works", (t) => {
const tool = new BashTool();
const expectedPwd = tool.executeSync("pwd").stdout.trim();
const snapshot = tool.snapshot();
const restored = BashTool.fromSnapshot(snapshot);
const snapshot = tool.snapshot({ hmacKey: snapshotKey });
const restored = BashTool.fromSnapshot(snapshot, undefined, {
hmacKey: snapshotKey,
});

t.is(restored.executeSync("pwd").stdout.trim(), expectedPwd);
t.is(restored.executeSync("echo ${MISSING:-unset}").stdout.trim(), "unset");
Expand All @@ -353,8 +364,59 @@ test("integration: BashTool invalid snapshot throws", (t) => {
const tool = new BashTool();
const invalid = new Uint8Array([0, 1, 2, 3, 4]);

t.throws(() => tool.restoreSnapshot(invalid));
t.throws(() => BashTool.fromSnapshot(invalid));
t.throws(() => tool.restoreSnapshot(invalid, { hmacKey: snapshotKey }));
t.throws(() =>
BashTool.fromSnapshot(invalid, undefined, { hmacKey: snapshotKey }),
);
});

test("integration: BashTool snapshots require and verify HMAC", (t) => {
const tool = new BashTool();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
t.throws(() => (tool as any).snapshot(), {
message: /hmacKey/,
});

const snapshot = tool.snapshot({ hmacKey: snapshotKey });
const tampered = new Uint8Array(snapshot);
tampered[tampered.length - 1] ^= 1;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
t.throws(() => (tool as any).restoreSnapshot(snapshot));
t.throws(() => tool.restoreSnapshot(tampered, { hmacKey: snapshotKey }));
t.throws(() =>
BashTool.fromSnapshot(snapshot, undefined, {
hmacKey: new TextEncoder().encode("wrong key"),
}),
);
});

test("integration: Bash snapshot with hmacKey roundtrip preserves state", (t) => {
const key = new TextEncoder().encode("bash-hmac-roundtrip-key");
const bash = new Bash();
bash.executeSync("export HMAC_VAR=hello; mkdir -p /hmac && echo data > /hmac/f.txt");

const snapshot = bash.snapshot({ hmacKey: key });
const restored = Bash.fromSnapshot(snapshot, { hmacKey: key });

t.is(restored.executeSync("echo $HMAC_VAR").stdout.trim(), "hello");
t.is(restored.executeSync("cat /hmac/f.txt").stdout.trim(), "data");
});

test("integration: Bash snapshot with hmacKey rejects wrong key and tampering", (t) => {
const key = new TextEncoder().encode("correct-hmac-key");
const wrongKey = new TextEncoder().encode("wrong-hmac-key");
const bash = new Bash();
bash.executeSync("export X=1");

const snapshot = bash.snapshot({ hmacKey: key });

t.throws(() => Bash.fromSnapshot(snapshot, { hmacKey: wrongKey }));

const tampered = new Uint8Array(snapshot);
tampered[tampered.length - 1] ^= 0xff;
t.throws(() => Bash.fromSnapshot(tampered, { hmacKey: key }));
});

test("integration: Bash keyed snapshot rejects wrong key", (t) => {
Expand Down
83 changes: 62 additions & 21 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,7 @@ pub struct BashOptions {
pub struct SnapshotOptions {
pub exclude_filesystem: Option<bool>,
pub exclude_functions: Option<bool>,
pub hmac_key: Option<napi::bindgen_prelude::Buffer>,
}

fn default_opts() -> BashOptions {
Expand Down Expand Up @@ -1055,10 +1056,9 @@ fn default_opts() -> BashOptions {
}
}

fn to_snapshot_options(options: Option<SnapshotOptions>) -> RustSnapshotOptions {
fn to_snapshot_options(options: Option<&SnapshotOptions>) -> RustSnapshotOptions {
RustSnapshotOptions {
exclude_filesystem: options
.as_ref()
.and_then(|options| options.exclude_filesystem)
.unwrap_or(false),
exclude_functions: options
Expand All @@ -1067,6 +1067,24 @@ fn to_snapshot_options(options: Option<SnapshotOptions>) -> RustSnapshotOptions
}
}

fn snapshot_hmac_key(options: Option<&SnapshotOptions>) -> Option<&[u8]> {
options.and_then(|options| options.hmac_key.as_deref())
}

fn require_snapshot_hmac_key(options: Option<&SnapshotOptions>) -> napi::Result<&[u8]> {
let Some(key) = snapshot_hmac_key(options) else {
return Err(napi::Error::from_reason(
"BashTool snapshots require SnapshotOptions.hmacKey for HMAC authentication",
));
};
if key.is_empty() {
return Err(napi::Error::from_reason(
"BashTool snapshots require a non-empty SnapshotOptions.hmacKey",
));
}
Ok(key)
}

// ============================================================================
// SharedState — all mutable state behind Arc to avoid raw pointer issues
// ============================================================================
Expand Down Expand Up @@ -1389,23 +1407,36 @@ impl Bash {
&self,
options: Option<SnapshotOptions>,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
let options = to_snapshot_options(options);
let snapshot_options = to_snapshot_options(options.as_ref());
let hmac_key = snapshot_hmac_key(options.as_ref()).map(Vec::from);
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let bytes = bash
.snapshot_with_options(options)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
let bytes = if let Some(key) = hmac_key.as_deref() {
bash.snapshot_to_bytes_keyed_with_options(key, snapshot_options)
} else {
bash.snapshot_with_options(snapshot_options)
}
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(napi::bindgen_prelude::Buffer::from(bytes))
})
}

/// Restore interpreter state from a snapshot previously created with `snapshot()`.
#[napi]
pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> {
pub fn restore_snapshot(
&self,
data: napi::bindgen_prelude::Buffer,
options: Option<SnapshotOptions>,
) -> napi::Result<()> {
let hmac_key = snapshot_hmac_key(options.as_ref()).map(Vec::from);
block_on_with(&self.state, |s| async move {
let mut bash = s.inner.lock().await;
bash.restore_snapshot(&data)
.map_err(|e| napi::Error::from_reason(e.to_string()))
if let Some(key) = hmac_key.as_deref() {
bash.restore_snapshot_keyed(&data, key)
} else {
bash.restore_snapshot(&data)
}
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}

Expand All @@ -1418,7 +1449,7 @@ impl Bash {
key: napi::bindgen_prelude::Buffer,
options: Option<SnapshotOptions>,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
let options = to_snapshot_options(options);
let options = to_snapshot_options(options.as_ref());
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let bytes = bash
Expand Down Expand Up @@ -1450,16 +1481,18 @@ impl Bash {
pub fn from_snapshot(
data: napi::bindgen_prelude::Buffer,
options: Option<BashOptions>,
snapshot_options: Option<SnapshotOptions>,
) -> napi::Result<Self> {
let opts = options.unwrap_or_else(default_opts);
let mut state = shared_state_from_opts(opts, None)?;

// restore_snapshot preserves the instance's limits while restoring shell state
state
.inner
.get_mut()
.restore_snapshot(&data)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
if let Some(key) = snapshot_hmac_key(snapshot_options.as_ref()) {
state.inner.get_mut().restore_snapshot_keyed(&data, key)
} else {
state.inner.get_mut().restore_snapshot(&data)
}
.map_err(|e| napi::Error::from_reason(e.to_string()))?;

Ok(Self {
state: Arc::new(state),
Expand Down Expand Up @@ -1928,22 +1961,28 @@ impl BashTool {
&self,
options: Option<SnapshotOptions>,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
let options = to_snapshot_options(options);
let key = require_snapshot_hmac_key(options.as_ref())?.to_vec();
let snapshot_options = to_snapshot_options(options.as_ref());
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let bytes = bash
.snapshot_with_options(options)
.snapshot_to_bytes_keyed_with_options(&key, snapshot_options)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(napi::bindgen_prelude::Buffer::from(bytes))
})
}

/// Restore interpreter state from a snapshot previously created with `snapshot()`.
#[napi]
pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> {
pub fn restore_snapshot(
&self,
data: napi::bindgen_prelude::Buffer,
options: Option<SnapshotOptions>,
) -> napi::Result<()> {
let key = require_snapshot_hmac_key(options.as_ref())?.to_vec();
block_on_with(&self.state, |s| async move {
let mut bash = s.inner.lock().await;
bash.restore_snapshot(&data)
bash.restore_snapshot_keyed(&data, &key)
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}
Expand All @@ -1955,7 +1994,7 @@ impl BashTool {
key: napi::bindgen_prelude::Buffer,
options: Option<SnapshotOptions>,
) -> napi::Result<napi::bindgen_prelude::Buffer> {
let options = to_snapshot_options(options);
let options = to_snapshot_options(options.as_ref());
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let bytes = bash
Expand Down Expand Up @@ -1987,14 +2026,16 @@ impl BashTool {
pub fn from_snapshot(
data: napi::bindgen_prelude::Buffer,
options: Option<BashOptions>,
snapshot_options: Option<SnapshotOptions>,
) -> napi::Result<Self> {
let opts = options.unwrap_or_else(default_opts);
let key = require_snapshot_hmac_key(snapshot_options.as_ref())?.to_vec();
let mut state = shared_state_from_opts(opts, None)?;

state
.inner
.get_mut()
.restore_snapshot(&data)
.restore_snapshot_keyed(&data, &key)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;

Ok(Self {
Expand Down
Loading
Loading