Skip to content

Commit 4cc7862

Browse files
committed
fix(security): revoke execution-scoped capabilities
1 parent 225a5d7 commit 4cc7862

31 files changed

Lines changed: 1299 additions & 205 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@ assert_eq!(bash.exec("greet Alice").await?.stdout, "hello Alice\n");
292292
# }
293293
```
294294

295+
Registry builtins get execution-scoped VFS/request handles by default. Only
296+
trusted host code that intentionally needs a session-lived VFS handle should
297+
use `registry.insert_trusted(...)`.
298+
295299
Node (`@everruns/bashkit`):
296300

297301
```typescript

crates/bashkit-python/src/lib.rs

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3963,7 +3963,7 @@ impl PyCustomBuiltinAdapter {
39633963
#[async_trait]
39643964
impl Builtin for PyCustomBuiltinAdapter {
39653965
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<RustExecResult> {
3966-
let session = ctx.execution_extension::<Arc<PyCallbackSession>>().cloned();
3966+
let session = ctx.execution_extension::<Arc<PyCallbackSession>>();
39673967
let builtin_arg = Python::attach(|py| -> Result<Py<PyAny>, String> {
39683968
let builtin_arg = make_py_builtin_context(py, &self.name, &ctx, &self.rt)
39693969
.map_err(|e| format!("{}: {}", self.name, e))?
@@ -3974,27 +3974,34 @@ impl Builtin for PyCustomBuiltinAdapter {
39743974
});
39753975
let callback_result = match builtin_arg {
39763976
Ok(builtin_arg) if self.is_async => match session {
3977-
Some(session) => {
3978-
call_python_callback_async(
3979-
session,
3980-
&self.name,
3981-
&self.callback,
3982-
vec![builtin_arg],
3983-
)
3984-
.await
3985-
}
3977+
Some(session) => match session.try_with(Clone::clone) {
3978+
Ok(callback_session) => session
3979+
.run(call_python_callback_async(
3980+
callback_session,
3981+
&self.name,
3982+
&self.callback,
3983+
vec![builtin_arg],
3984+
))
3985+
.await
3986+
.unwrap_or_else(|error| Err(format!("{}: {error}", self.name))),
3987+
Err(error) => Err(format!("{}: {error}", self.name)),
3988+
},
39863989
None => Err(format!("{}: missing Python callback session", self.name)),
39873990
},
39883991
Ok(builtin_arg) => match session {
3989-
Some(session) => Python::attach(|py| {
3990-
call_python_callback_sync(
3991-
py,
3992-
session.as_ref(),
3993-
&self.name,
3994-
&self.callback,
3995-
vec![builtin_arg],
3996-
)
3997-
}),
3992+
Some(session) => session
3993+
.try_with(|session| {
3994+
Python::attach(|py| {
3995+
call_python_callback_sync(
3996+
py,
3997+
session.as_ref(),
3998+
&self.name,
3999+
&self.callback,
4000+
vec![builtin_arg],
4001+
)
4002+
})
4003+
})
4004+
.unwrap_or_else(|error| Err(format!("{}: {error}", self.name))),
39984005
None => Err(format!("{}: missing Python callback session", self.name)),
39994006
},
40004007
Err(err) => Err(err),

crates/bashkit/docs/custom_builtins.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Custom Builtins
22

33
Bashkit supports registering custom builtin commands to extend the shell with
4-
domain-specific functionality. Custom builtins have full access to the execution
5-
context including arguments, environment variables, shell variables, and the
6-
virtual filesystem.
4+
domain-specific functionality. Custom builtins receive an execution-scoped
5+
context including arguments, environment variables, shell variables, and a
6+
revocable virtual-filesystem view. Retaining the VFS or extension handles is
7+
safe: access fails deterministically after that `exec*()` completes or is cancelled.
78

89
**See also:**
910
- [API Documentation](https://docs.rs/bashkit) - Full API reference
@@ -95,7 +96,7 @@ impl Builtin for RequestId {
9596
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
9697
let req = ctx
9798
.execution_extension::<String>()
98-
.cloned()
99+
.and_then(|req| req.try_with(Clone::clone).ok())
99100
.unwrap_or_else(|| "missing".to_string());
100101
Ok(ExecResult::ok(format!("{req}\n")))
101102
}
@@ -247,6 +248,10 @@ The registry is host-owned: not part of interpreter state, so it survives
247248
`exec()` calls automatically and is not serialized by `Bash::snapshot()`.
248249
Re-attach the handle after restoring from a snapshot.
249250

251+
`BuiltinRegistry::insert` uses the same execution-scoped facilities as builder
252+
builtins. A trusted embedder that deliberately needs a retained, session-lived
253+
VFS handle must opt in with `insert_trusted`; do not use it for tenant/plugin code.
254+
250255
### Arguments
251256

252257
Arguments are passed as a slice of strings, excluding the command name itself:

crates/bashkit/docs/threat-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ echo $user_input
507507
| `$?` leaks into VFS subprocess (TM-ISO-024) | Parent `last_exit_code` visible in child, causing false `set -e` failures | Child resets `last_exit_code`, `nounset_error`, and traps | **MITIGATED** |
508508
| Wrapper rebuild drops constructor capabilities (TM-ISO-025) | A binding reset loses limits, policy files, callbacks, or network policy | Canonical capability matrix with executable evidence; rebuilds retain constructor config | **MITIGATED** |
509509
| Shared ToolRegistry request context (TM-ISO-026) | Concurrent shell/Python/TypeScript calls leak tenant identity or traces | Per-request `ExecutionExtensions`, task-local runtime routing, and callback-owned context | **MITIGATED** |
510-
| Stale request execution authority (TM-ISO-027) | A late runtime/transport/callback result crosses completion, cancellation, timeout, or reuse | Shared request budget, cancellation-aware awaits, post-await checks, and RAII closure/release | **MITIGATED** |
510+
| Stale request authority and retained host-extension handles (TM-ISO-027) | A late runtime/transport/callback result crosses completion, or a builtin/tool keeps VFS or request context past completion/cancellation | Shared request budget plus one revocable capability lease, cancellation-aware awaits, deterministic late-use failure, RAII closure/release, and explicit `insert_trusted` escape hatch | **MITIGATED** |
511511

512512
Each [`Bash`] instance is fully isolated. For multi-tenant environments, create
513513
separate instances per tenant:

crates/bashkit/src/builtins/awk/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,10 +562,12 @@ impl Builtin for Awk {
562562
let program = parser.parse()?;
563563

564564
let mut interp = AwkInterpreter::new();
565-
interp.execution_budget = ctx.execution_budget().cloned();
565+
interp.execution_budget = ctx
566+
.execution_budget()
567+
.and_then(|budget| budget.try_with(Clone::clone).ok());
566568
interp.max_loop_iterations = ctx
567569
.execution_extension::<ExecutionLimits>()
568-
.map(|limits| limits.max_loop_iterations)
570+
.and_then(|limits| limits.try_with(|limits| limits.max_loop_iterations).ok())
569571
.unwrap_or_else(|| ExecutionLimits::default().max_loop_iterations);
570572
interp.functions = program.functions.clone();
571573
interp.state.fs = Self::process_escape_sequences(&field_sep);

crates/bashkit/src/builtins/jq/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result<ExecResult> {
349349
// periodically so a runaway filter aborts instead of wedging the host.
350350
let max_output_bytes = ctx
351351
.execution_extension::<ExecutionLimits>()
352-
.map(|l| l.max_stdout_bytes)
352+
.and_then(|limits| limits.try_with(|limits| limits.max_stdout_bytes).ok())
353353
.unwrap_or_else(|| ExecutionLimits::default().max_stdout_bytes);
354354
let deadline = ctx.execution_extension::<ExecutionDeadline>();
355355
let mut values_emitted: usize = 0;
@@ -431,7 +431,11 @@ async fn run_jq(ctx: Context<'_>, parsed: JqArgs<'_>) -> Result<ExecResult> {
431431
}
432432
values_emitted += 1;
433433
if values_emitted.is_multiple_of(4096)
434-
&& deadline.is_some_and(ExecutionDeadline::is_expired)
434+
&& deadline.as_ref().is_some_and(|deadline| {
435+
deadline
436+
.try_with(ExecutionDeadline::is_expired)
437+
.unwrap_or(true)
438+
})
435439
{
436440
return Ok(ExecResult::err("jq: execution timed out\n".to_string(), 5));
437441
}

0 commit comments

Comments
 (0)