Skip to content

Commit 21aee56

Browse files
committed
fix(js): authenticate BashTool snapshots
1 parent 83cbac6 commit 21aee56

4 files changed

Lines changed: 183 additions & 61 deletions

File tree

crates/bashkit-js/README.md

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,13 @@ in flight, so the underlying `Promise<string>` callback could never run.
355355

356356
## Snapshot / Restore
357357

358-
State snapshots are available on both `Bash` and `BashTool` instances:
358+
State snapshots are available on both `Bash` and `BashTool` instances.
359+
360+
Security: unkeyed `Bash` snapshots use a public corruption-detection digest and
361+
are forgeable. Use `hmacKey` whenever snapshot bytes cross a trust boundary
362+
(network, user uploads, shared storage). `BashTool` snapshots require `hmacKey`
363+
because they include tool session state, VFS contents, and counters that may be
364+
restored in multi-tenant agent services.
359365

360366
```typescript
361367
import { Bash, BashTool } from "@everruns/bashkit";
@@ -383,15 +389,20 @@ console.log(restored.executeSync("pwd").stdout); // /workspace\n
383389
const tool = new BashTool({ username: "agent", maxCommands: 5 });
384390
tool.executeSync("export TOOL_STATE=ready");
385391

386-
const toolSnapshot = tool.snapshot();
387-
const toolShellOnly = tool.snapshot({ excludeFilesystem: true });
388-
const restoredTool = BashTool.fromSnapshot(toolSnapshot, {
389-
username: "agent",
390-
maxCommands: 5,
391-
});
392+
const hmacKey = new TextEncoder().encode(process.env.SNAPSHOT_SECRET!);
393+
const toolSnapshot = tool.snapshot({ hmacKey });
394+
const toolShellOnly = tool.snapshot({ excludeFilesystem: true, hmacKey });
395+
const restoredTool = BashTool.fromSnapshot(
396+
toolSnapshot,
397+
{
398+
username: "agent",
399+
maxCommands: 5,
400+
},
401+
{ hmacKey },
402+
);
392403

393404
console.log(restoredTool.executeSync("echo $TOOL_STATE").stdout); // ready\n
394-
restoredTool.restoreSnapshot(toolShellOnly);
405+
restoredTool.restoreSnapshot(toolShellOnly, { hmacKey });
395406
```
396407

397408
## Framework Integrations
@@ -443,18 +454,18 @@ import {
443454
- `clearCancel()`
444455
- `reset()`
445456
- `addBuiltin(name, callback)` / `removeBuiltin(name)` — register/unregister persistent JS builtins
446-
- `snapshot()`
447-
- `restoreSnapshot(data)`
448-
- `Bash.fromSnapshot(data)`
457+
- `snapshot(options?)`
458+
- `restoreSnapshot(data, options?)`
459+
- `Bash.fromSnapshot(data, options?)`
449460
- Direct VFS helpers: `readFile`, `writeFile`, `appendFile`, `mkdir`, `remove`, `exists`, `stat`, `readDir`, `ls`, `glob`, `mount`, `unmount`, `fs`
450461

451462
### BashTool
452463

453464
- All execution, cancellation (`cancel()`, `clearCancel()`), reset, custom builtins, snapshot, restore, and direct VFS helpers from `Bash`
454465
- Tool metadata: `name`, `version`, `shortDescription`
455-
- `snapshot()`
456-
- `restoreSnapshot(data)`
457-
- `BashTool.fromSnapshot(data, options?)`
466+
- `snapshot({ hmacKey, ...options })`
467+
- `restoreSnapshot(data, { hmacKey })`
468+
- `BashTool.fromSnapshot(data, options?, { hmacKey })`
458469
- `description()`
459470
- `help()`
460471
- `systemPrompt()`

crates/bashkit-js/__test__/integration.spec.ts

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ test("integration: BashTool reset clears state", (t) => {
269269
t.is(tool.executeSync("whoami").stdout.trim(), "testuser");
270270
});
271271

272+
const snapshotKey = new TextEncoder().encode("integration snapshot hmac key");
273+
272274
test("integration: BashTool snapshot roundtrip preserves state and config", (t) => {
273275
const tool = new BashTool({
274276
username: "agent",
@@ -279,12 +281,16 @@ test("integration: BashTool snapshot roundtrip preserves state and config", (t)
279281
"export BUILD_ID=42; mkdir -p /workspace && cd /workspace && echo ready > state.txt",
280282
);
281283

282-
const snapshot = tool.snapshot();
283-
const restored = BashTool.fromSnapshot(snapshot, {
284-
username: "agent",
285-
maxCommands: 5,
286-
maxLoopIterations: 50,
287-
});
284+
const snapshot = tool.snapshot({ hmacKey: snapshotKey });
285+
const restored = BashTool.fromSnapshot(
286+
snapshot,
287+
{
288+
username: "agent",
289+
maxCommands: 5,
290+
maxLoopIterations: 50,
291+
},
292+
{ hmacKey: snapshotKey },
293+
);
288294

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

305-
const snapshot = tool.snapshot();
311+
const snapshot = tool.snapshot({ hmacKey: snapshotKey });
306312

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

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

333-
const snapshot = tool.snapshot({ excludeFilesystem: true });
339+
const snapshot = tool.snapshot({
340+
excludeFilesystem: true,
341+
hmacKey: snapshotKey,
342+
});
334343

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

338347
t.is(tool.executeSync("echo $KEEP").stdout.trim(), "1");
339348
t.is(tool.executeSync("cat /tmp/tool.txt").stdout.trim(), "changed");
@@ -342,8 +351,10 @@ test("integration: BashTool snapshot can exclude filesystem", (t) => {
342351
test("integration: BashTool empty snapshot roundtrip works", (t) => {
343352
const tool = new BashTool();
344353
const expectedPwd = tool.executeSync("pwd").stdout.trim();
345-
const snapshot = tool.snapshot();
346-
const restored = BashTool.fromSnapshot(snapshot);
354+
const snapshot = tool.snapshot({ hmacKey: snapshotKey });
355+
const restored = BashTool.fromSnapshot(snapshot, undefined, {
356+
hmacKey: snapshotKey,
357+
});
347358

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

356-
t.throws(() => tool.restoreSnapshot(invalid));
357-
t.throws(() => BashTool.fromSnapshot(invalid));
367+
t.throws(() => tool.restoreSnapshot(invalid, { hmacKey: snapshotKey }));
368+
t.throws(() =>
369+
BashTool.fromSnapshot(invalid, undefined, { hmacKey: snapshotKey }),
370+
);
371+
});
372+
373+
test("integration: BashTool snapshots require and verify HMAC", (t) => {
374+
const tool = new BashTool();
375+
376+
t.throws(() => tool.snapshot(), {
377+
message: /hmacKey/,
378+
});
379+
380+
const snapshot = tool.snapshot({ hmacKey: snapshotKey });
381+
const tampered = new Uint8Array(snapshot);
382+
tampered[tampered.length - 1] ^= 1;
383+
384+
t.throws(() => tool.restoreSnapshot(snapshot));
385+
t.throws(() => tool.restoreSnapshot(tampered, { hmacKey: snapshotKey }));
386+
t.throws(() =>
387+
BashTool.fromSnapshot(snapshot, undefined, {
388+
hmacKey: new TextEncoder().encode("wrong key"),
389+
}),
390+
);
358391
});
359392

360393
test("integration: multiple resets remain stable", (t) => {

crates/bashkit-js/src/lib.rs

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,7 @@ pub struct BashOptions {
10271027
pub struct SnapshotOptions {
10281028
pub exclude_filesystem: Option<bool>,
10291029
pub exclude_functions: Option<bool>,
1030+
pub hmac_key: Option<napi::bindgen_prelude::Buffer>,
10301031
}
10311032

10321033
fn default_opts() -> BashOptions {
@@ -1055,10 +1056,9 @@ fn default_opts() -> BashOptions {
10551056
}
10561057
}
10571058

1058-
fn to_snapshot_options(options: Option<SnapshotOptions>) -> RustSnapshotOptions {
1059+
fn to_snapshot_options(options: Option<&SnapshotOptions>) -> RustSnapshotOptions {
10591060
RustSnapshotOptions {
10601061
exclude_filesystem: options
1061-
.as_ref()
10621062
.and_then(|options| options.exclude_filesystem)
10631063
.unwrap_or(false),
10641064
exclude_functions: options
@@ -1067,6 +1067,24 @@ fn to_snapshot_options(options: Option<SnapshotOptions>) -> RustSnapshotOptions
10671067
}
10681068
}
10691069

1070+
fn snapshot_hmac_key(options: Option<&SnapshotOptions>) -> Option<&[u8]> {
1071+
options.and_then(|options| options.hmac_key.as_deref())
1072+
}
1073+
1074+
fn require_snapshot_hmac_key<'a>(options: Option<&'a SnapshotOptions>) -> napi::Result<&'a [u8]> {
1075+
let Some(key) = snapshot_hmac_key(options) else {
1076+
return Err(napi::Error::from_reason(
1077+
"BashTool snapshots require SnapshotOptions.hmacKey for HMAC authentication",
1078+
));
1079+
};
1080+
if key.is_empty() {
1081+
return Err(napi::Error::from_reason(
1082+
"BashTool snapshots require a non-empty SnapshotOptions.hmacKey",
1083+
));
1084+
}
1085+
Ok(key)
1086+
}
1087+
10701088
// ============================================================================
10711089
// SharedState — all mutable state behind Arc to avoid raw pointer issues
10721090
// ============================================================================
@@ -1389,23 +1407,36 @@ impl Bash {
13891407
&self,
13901408
options: Option<SnapshotOptions>,
13911409
) -> napi::Result<napi::bindgen_prelude::Buffer> {
1392-
let options = to_snapshot_options(options);
1410+
let snapshot_options = to_snapshot_options(options.as_ref());
1411+
let hmac_key = snapshot_hmac_key(options.as_ref()).map(Vec::from);
13931412
block_on_with(&self.state, |s| async move {
13941413
let bash = s.inner.lock().await;
1395-
let bytes = bash
1396-
.snapshot_with_options(options)
1397-
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
1414+
let bytes = if let Some(key) = hmac_key.as_deref() {
1415+
bash.snapshot_to_bytes_keyed_with_options(key, snapshot_options)
1416+
} else {
1417+
bash.snapshot_with_options(snapshot_options)
1418+
}
1419+
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
13981420
Ok(napi::bindgen_prelude::Buffer::from(bytes))
13991421
})
14001422
}
14011423

14021424
/// Restore interpreter state from a snapshot previously created with `snapshot()`.
14031425
#[napi]
1404-
pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> {
1426+
pub fn restore_snapshot(
1427+
&self,
1428+
data: napi::bindgen_prelude::Buffer,
1429+
options: Option<SnapshotOptions>,
1430+
) -> napi::Result<()> {
1431+
let hmac_key = snapshot_hmac_key(options.as_ref()).map(Vec::from);
14051432
block_on_with(&self.state, |s| async move {
14061433
let mut bash = s.inner.lock().await;
1407-
bash.restore_snapshot(&data)
1408-
.map_err(|e| napi::Error::from_reason(e.to_string()))
1434+
if let Some(key) = hmac_key.as_deref() {
1435+
bash.restore_snapshot_keyed(&data, key)
1436+
} else {
1437+
bash.restore_snapshot(&data)
1438+
}
1439+
.map_err(|e| napi::Error::from_reason(e.to_string()))
14091440
})
14101441
}
14111442

@@ -1417,16 +1448,18 @@ impl Bash {
14171448
pub fn from_snapshot(
14181449
data: napi::bindgen_prelude::Buffer,
14191450
options: Option<BashOptions>,
1451+
snapshot_options: Option<SnapshotOptions>,
14201452
) -> napi::Result<Self> {
14211453
let opts = options.unwrap_or_else(default_opts);
14221454
let mut state = shared_state_from_opts(opts, None)?;
14231455

14241456
// restore_snapshot preserves the instance's limits while restoring shell state
1425-
state
1426-
.inner
1427-
.get_mut()
1428-
.restore_snapshot(&data)
1429-
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
1457+
if let Some(key) = snapshot_hmac_key(snapshot_options.as_ref()) {
1458+
state.inner.get_mut().restore_snapshot_keyed(&data, key)
1459+
} else {
1460+
state.inner.get_mut().restore_snapshot(&data)
1461+
}
1462+
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
14301463

14311464
Ok(Self {
14321465
state: Arc::new(state),
@@ -1872,22 +1905,28 @@ impl BashTool {
18721905
&self,
18731906
options: Option<SnapshotOptions>,
18741907
) -> napi::Result<napi::bindgen_prelude::Buffer> {
1875-
let options = to_snapshot_options(options);
1908+
let key = require_snapshot_hmac_key(options.as_ref())?.to_vec();
1909+
let snapshot_options = to_snapshot_options(options.as_ref());
18761910
block_on_with(&self.state, |s| async move {
18771911
let bash = s.inner.lock().await;
18781912
let bytes = bash
1879-
.snapshot_with_options(options)
1913+
.snapshot_to_bytes_keyed_with_options(&key, snapshot_options)
18801914
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
18811915
Ok(napi::bindgen_prelude::Buffer::from(bytes))
18821916
})
18831917
}
18841918

18851919
/// Restore interpreter state from a snapshot previously created with `snapshot()`.
18861920
#[napi]
1887-
pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> {
1921+
pub fn restore_snapshot(
1922+
&self,
1923+
data: napi::bindgen_prelude::Buffer,
1924+
options: Option<SnapshotOptions>,
1925+
) -> napi::Result<()> {
1926+
let key = require_snapshot_hmac_key(options.as_ref())?.to_vec();
18881927
block_on_with(&self.state, |s| async move {
18891928
let mut bash = s.inner.lock().await;
1890-
bash.restore_snapshot(&data)
1929+
bash.restore_snapshot_keyed(&data, &key)
18911930
.map_err(|e| napi::Error::from_reason(e.to_string()))
18921931
})
18931932
}
@@ -1900,14 +1939,16 @@ impl BashTool {
19001939
pub fn from_snapshot(
19011940
data: napi::bindgen_prelude::Buffer,
19021941
options: Option<BashOptions>,
1942+
snapshot_options: Option<SnapshotOptions>,
19031943
) -> napi::Result<Self> {
19041944
let opts = options.unwrap_or_else(default_opts);
1945+
let key = require_snapshot_hmac_key(snapshot_options.as_ref())?.to_vec();
19051946
let mut state = shared_state_from_opts(opts, None)?;
19061947

19071948
state
19081949
.inner
19091950
.get_mut()
1910-
.restore_snapshot(&data)
1951+
.restore_snapshot_keyed(&data, &key)
19111952
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
19121953

19131954
Ok(Self {

0 commit comments

Comments
 (0)