From ef3a68e53ce9910797cc32dba0bd8352ea0285b7 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 1 Jun 2026 23:17:01 -0500 Subject: [PATCH 1/5] fix(js): authenticate BashTool snapshots --- crates/bashkit-js/README.md | 40 ++++++---- .../bashkit-js/__test__/integration.spec.ts | 61 ++++++++++---- crates/bashkit-js/src/lib.rs | 79 ++++++++++++++----- crates/bashkit-js/wrapper.ts | 65 +++++++++++---- 4 files changed, 182 insertions(+), 63 deletions(-) diff --git a/crates/bashkit-js/README.md b/crates/bashkit-js/README.md index 21a427cac..d744519a2 100644 --- a/crates/bashkit-js/README.md +++ b/crates/bashkit-js/README.md @@ -357,9 +357,12 @@ in flight, so the underlying `Promise` 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"; @@ -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 @@ -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, options?)` - 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()` diff --git a/crates/bashkit-js/__test__/integration.spec.ts b/crates/bashkit-js/__test__/integration.spec.ts index 9fe2846d2..8015ca44d 100644 --- a/crates/bashkit-js/__test__/integration.spec.ts +++ b/crates/bashkit-js/__test__/integration.spec.ts @@ -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"); + test("integration: BashTool snapshot roundtrip preserves state and config", (t) => { const tool = new BashTool({ username: "agent", @@ -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"); @@ -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"); @@ -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"); @@ -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"); @@ -353,8 +364,30 @@ 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(); + + t.throws(() => tool.snapshot(), { + message: /hmacKey/, + }); + + const snapshot = tool.snapshot({ hmacKey: snapshotKey }); + const tampered = new Uint8Array(snapshot); + tampered[tampered.length - 1] ^= 1; + + t.throws(() => tool.restoreSnapshot(snapshot)); + t.throws(() => tool.restoreSnapshot(tampered, { hmacKey: snapshotKey })); + t.throws(() => + BashTool.fromSnapshot(snapshot, undefined, { + hmacKey: new TextEncoder().encode("wrong key"), + }), + ); }); test("integration: Bash keyed snapshot rejects wrong key", (t) => { diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 8be83ef0c..a6c7d28a9 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -1027,6 +1027,7 @@ pub struct BashOptions { pub struct SnapshotOptions { pub exclude_filesystem: Option, pub exclude_functions: Option, + pub hmac_key: Option, } fn default_opts() -> BashOptions { @@ -1055,10 +1056,9 @@ fn default_opts() -> BashOptions { } } -fn to_snapshot_options(options: Option) -> 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 @@ -1067,6 +1067,24 @@ fn to_snapshot_options(options: Option) -> RustSnapshotOptions } } +fn snapshot_hmac_key(options: Option<&SnapshotOptions>) -> Option<&[u8]> { + options.and_then(|options| options.hmac_key.as_deref()) +} + +fn require_snapshot_hmac_key<'a>(options: Option<&'a SnapshotOptions>) -> napi::Result<&'a [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 // ============================================================================ @@ -1389,23 +1407,36 @@ impl Bash { &self, options: Option, ) -> napi::Result { - 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, + ) -> 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())) }) } @@ -1450,16 +1481,18 @@ impl Bash { pub fn from_snapshot( data: napi::bindgen_prelude::Buffer, options: Option, + snapshot_options: Option, ) -> napi::Result { 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), @@ -1928,11 +1961,12 @@ impl BashTool { &self, options: Option, ) -> napi::Result { - 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)) }) @@ -1940,10 +1974,15 @@ impl BashTool { /// 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, + ) -> 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())) }) } @@ -1987,14 +2026,16 @@ impl BashTool { pub fn from_snapshot( data: napi::bindgen_prelude::Buffer, options: Option, + snapshot_options: Option, ) -> napi::Result { 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 { diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index ff075a02c..790aac298 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -257,6 +257,13 @@ export interface BashOptions { export interface SnapshotOptions { excludeFilesystem?: boolean; excludeFunctions?: boolean; + /** + * Secret key used to authenticate snapshot bytes with HMAC-SHA256. + * Required for BashTool snapshots because tool snapshots may cross tenant + * or network trust boundaries. Recommended for any snapshot accepted from + * users, shared storage, or remote callers. + */ + hmacKey?: Uint8Array; } export interface OutputChunk { @@ -461,9 +468,18 @@ function toNativeSnapshotOptions( return { excludeFilesystem: options.excludeFilesystem, excludeFunctions: options.excludeFunctions, + hmacKey: options.hmacKey ? Buffer.from(options.hmacKey) : undefined, }; } +function requireSnapshotHmacKey(options?: SnapshotOptions): void { + if (!options?.hmacKey || options.hmacKey.byteLength === 0) { + throw new Error( + "BashTool snapshots require SnapshotOptions.hmacKey for HMAC authentication", + ); + } +} + function isFileSystemLike(value: unknown): value is { toExternal(): unknown } { return ( typeof (value as { toExternal?: unknown } | null)?.toExternal === "function" @@ -854,8 +870,9 @@ export class Bash { /** * Serialize interpreter state (variables, VFS, counters) to a Uint8Array. * - * The snapshot can be persisted to disk, sent over the network, and later - * used with `Bash.fromSnapshot()` to restore the session. + * Use `hmacKey` when snapshots are stored outside the current trust boundary + * (network, user uploads, shared storage). Without `hmacKey`, the snapshot + * digest only detects accidental corruption and is forgeable. * * @example * ```typescript @@ -876,8 +893,11 @@ export class Bash { * Preserves current configuration (limits, builtins) but replaces * shell state and VFS contents. */ - restoreSnapshot(data: Uint8Array): void { - this.native.restoreSnapshot(Buffer.from(data)); + restoreSnapshot(data: Uint8Array, options?: SnapshotOptions): void { + this.native.restoreSnapshot( + Buffer.from(data), + toNativeSnapshotOptions(options), + ); } /** @@ -907,9 +927,13 @@ export class Bash { * const restored = Bash.fromSnapshot(snapshot); * ``` */ - static fromSnapshot(data: Uint8Array): Bash { + static fromSnapshot(data: Uint8Array, options?: SnapshotOptions): Bash { const instance = new Bash(); - instance.native = NativeBash.fromSnapshot(Buffer.from(data)); + instance.native = NativeBash.fromSnapshot( + Buffer.from(data), + undefined, + toNativeSnapshotOptions(options), + ); return instance; } @@ -1229,19 +1253,26 @@ export class BashTool { } /** - * Serialize interpreter state (variables, VFS, counters) to a Uint8Array. + * Serialize interpreter state (variables, VFS, counters) to an + * HMAC-authenticated Uint8Array. BashTool snapshots require `hmacKey` because + * they include tenant-controlled shell state, VFS contents, and counters. */ snapshot(options?: SnapshotOptions): Uint8Array { + requireSnapshotHmacKey(options); return this.native.snapshot(toNativeSnapshotOptions(options)); } /** - * Restore interpreter state from a previously captured snapshot. + * Restore interpreter state from an HMAC-authenticated snapshot. * Preserves current configuration (limits, identity) but replaces * shell state and VFS contents. */ - restoreSnapshot(data: Uint8Array): void { - this.native.restoreSnapshot(Buffer.from(data)); + restoreSnapshot(data: Uint8Array, options?: SnapshotOptions): void { + requireSnapshotHmacKey(options); + this.native.restoreSnapshot( + Buffer.from(data), + toNativeSnapshotOptions(options), + ); } /** @@ -1262,17 +1293,23 @@ export class BashTool { } /** - * Create a new BashTool instance from a snapshot. + * Create a new BashTool instance from an HMAC-authenticated snapshot. * - * Any provided options are applied before restoring the snapshot so limits - * and identity settings survive round-trips. + * Any provided Bash options are applied before restoring the snapshot so + * limits and identity settings survive round-trips. */ - static fromSnapshot(data: Uint8Array, options?: BashOptions): BashTool { + static fromSnapshot( + data: Uint8Array, + options?: BashOptions, + snapshotOptions?: SnapshotOptions, + ): BashTool { + requireSnapshotHmacKey(snapshotOptions); const resolved = resolveFilesSync(options?.files); const instance = Object.create(BashTool.prototype) as BashTool; instance.native = NativeBashTool.fromSnapshot( Buffer.from(data), toNativeOptions(options, resolved), + toNativeSnapshotOptions(snapshotOptions), ); return instance; } From 64b68b520d3958f2b1e7af2375c04077d2e38c06 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 2 Jun 2026 13:37:03 +0000 Subject: [PATCH 2/5] fix(js): pass options by ref in snapshot_keyed methods --- crates/bashkit-js/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index a6c7d28a9..0ffb1c3c8 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -1449,7 +1449,7 @@ impl Bash { key: napi::bindgen_prelude::Buffer, options: Option, ) -> napi::Result { - 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 @@ -1994,7 +1994,7 @@ impl BashTool { key: napi::bindgen_prelude::Buffer, options: Option, ) -> napi::Result { - 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 From 7e65582dcc12b22e8821857f3d636e11b421530b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 2 Jun 2026 13:38:49 +0000 Subject: [PATCH 3/5] fix(js): elide needless lifetime in require_snapshot_hmac_key --- crates/bashkit-js/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 0ffb1c3c8..af731a7cc 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -1071,7 +1071,7 @@ fn snapshot_hmac_key(options: Option<&SnapshotOptions>) -> Option<&[u8]> { options.and_then(|options| options.hmac_key.as_deref()) } -fn require_snapshot_hmac_key<'a>(options: Option<&'a SnapshotOptions>) -> napi::Result<&'a [u8]> { +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", From e7a7f54087318eb6c681b78b029a7a5ad641be32 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 2 Jun 2026 13:47:02 +0000 Subject: [PATCH 4/5] fix(js): enforce hmacKey at compile time for BashTool snapshot APIs - BashTool.snapshot/restoreSnapshot require SnapshotOptions & { hmacKey } - BashTool.fromSnapshot second param is BashOptions | undefined so third (snapshotOptions with required hmacKey) can be non-optional - Add Bash hmacKey roundtrip and tamper-rejection integration tests - Fix README: Bash.fromSnapshotKeyed has no options parameter --- crates/bashkit-js/README.md | 2 +- .../bashkit-js/__test__/integration.spec.ts | 27 +++++++++++++++++++ crates/bashkit-js/wrapper.ts | 11 +++++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/bashkit-js/README.md b/crates/bashkit-js/README.md index d744519a2..320c0ec07 100644 --- a/crates/bashkit-js/README.md +++ b/crates/bashkit-js/README.md @@ -462,7 +462,7 @@ import { - `addBuiltin(name, callback)` / `removeBuiltin(name)` — register/unregister persistent JS builtins - `snapshot(options?)` / `snapshotKeyed(key, options?)` - `restoreSnapshot(data, options?)` / `restoreSnapshotKeyed(data, key)` -- `Bash.fromSnapshot(data, options?)` / `Bash.fromSnapshotKeyed(data, key, options?)` +- `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 diff --git a/crates/bashkit-js/__test__/integration.spec.ts b/crates/bashkit-js/__test__/integration.spec.ts index 8015ca44d..d38d97596 100644 --- a/crates/bashkit-js/__test__/integration.spec.ts +++ b/crates/bashkit-js/__test__/integration.spec.ts @@ -390,6 +390,33 @@ test("integration: BashTool snapshots require and verify HMAC", (t) => { ); }); +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) => { const key = Buffer.from("correct-key"); const wrongKey = Buffer.from("wrong-key"); diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 790aac298..4599290fe 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -1257,7 +1257,7 @@ export class BashTool { * HMAC-authenticated Uint8Array. BashTool snapshots require `hmacKey` because * they include tenant-controlled shell state, VFS contents, and counters. */ - snapshot(options?: SnapshotOptions): Uint8Array { + snapshot(options: SnapshotOptions & { hmacKey: Uint8Array }): Uint8Array { requireSnapshotHmacKey(options); return this.native.snapshot(toNativeSnapshotOptions(options)); } @@ -1267,7 +1267,10 @@ export class BashTool { * Preserves current configuration (limits, identity) but replaces * shell state and VFS contents. */ - restoreSnapshot(data: Uint8Array, options?: SnapshotOptions): void { + restoreSnapshot( + data: Uint8Array, + options: SnapshotOptions & { hmacKey: Uint8Array }, + ): void { requireSnapshotHmacKey(options); this.native.restoreSnapshot( Buffer.from(data), @@ -1300,8 +1303,8 @@ export class BashTool { */ static fromSnapshot( data: Uint8Array, - options?: BashOptions, - snapshotOptions?: SnapshotOptions, + options: BashOptions | undefined, + snapshotOptions: SnapshotOptions & { hmacKey: Uint8Array }, ): BashTool { requireSnapshotHmacKey(snapshotOptions); const resolved = resolveFilesSync(options?.files); From 3bdf267c8058c009ac53a25bdc5b277b8a51bdd0 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 2 Jun 2026 13:51:16 +0000 Subject: [PATCH 5/5] fix(js): cast to any in runtime-guard tests to satisfy strict tsconfig.check.json --- crates/bashkit-js/__test__/integration.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/bashkit-js/__test__/integration.spec.ts b/crates/bashkit-js/__test__/integration.spec.ts index d38d97596..6c383efae 100644 --- a/crates/bashkit-js/__test__/integration.spec.ts +++ b/crates/bashkit-js/__test__/integration.spec.ts @@ -373,7 +373,8 @@ test("integration: BashTool invalid snapshot throws", (t) => { test("integration: BashTool snapshots require and verify HMAC", (t) => { const tool = new BashTool(); - t.throws(() => tool.snapshot(), { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t.throws(() => (tool as any).snapshot(), { message: /hmacKey/, }); @@ -381,7 +382,8 @@ test("integration: BashTool snapshots require and verify HMAC", (t) => { const tampered = new Uint8Array(snapshot); tampered[tampered.length - 1] ^= 1; - t.throws(() => tool.restoreSnapshot(snapshot)); + // 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, {