Skip to content

Commit b9b6484

Browse files
committed
feat(config): add typed execution profiles
1 parent 8e89f1b commit b9b6484

35 files changed

Lines changed: 1247 additions & 102 deletions

File tree

crates/bashkit-capi/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ x86-64 application cannot load an ARM64 Bashkit library.
166166
```json
167167
{
168168
"schema_version": 1,
169+
"profile": "standard",
169170
"cwd": "/workspace",
170171
"env": {"CI": "true"},
171172
"files": {"/workspace/input.txt": "hello\n"},
@@ -187,6 +188,9 @@ Unknown fields and schema versions are rejected. Text files may be initialized
187188
through `files`; use `bashkit_write_file()` for arbitrary bytes. Configuration
188189
input is capped at `BASHKIT_MAX_CONFIG_BYTES` (10 MB).
189190

191+
`profile` is a closed enum: `"hardened"`, `"standard"`, or `"interactive"`.
192+
It defaults to `"standard"`; fields under `limits` are explicit overrides.
193+
190194
## Contract
191195

192196
- Inputs are borrowed only for the duration of a call.

crates/bashkit-capi/src/lib.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// boundary and reports a generic error so unwinding cannot enter the foreign
66
// caller and the returned error does not include panic details.
77

8-
use bashkit::{Bash, Error as BashError, ExecutionLimits, FileSystem};
8+
use bashkit::{Bash, Error as BashError, FileSystem};
99
use serde::Deserialize;
1010
use std::collections::{BTreeMap, HashMap};
1111
use std::panic::{AssertUnwindSafe, catch_unwind};
@@ -121,6 +121,8 @@ impl ApiFailure {
121121
struct ConfigV1 {
122122
schema_version: u32,
123123
#[serde(default)]
124+
profile: ProfileConfigV1,
125+
#[serde(default)]
124126
cwd: Option<String>,
125127
#[serde(default)]
126128
env: BTreeMap<String, String>,
@@ -138,6 +140,25 @@ struct ConfigV1 {
138140
capture_final_env: bool,
139141
}
140142

143+
#[derive(Clone, Copy, Default, Deserialize)]
144+
#[serde(rename_all = "snake_case")]
145+
enum ProfileConfigV1 {
146+
Hardened,
147+
#[default]
148+
Standard,
149+
Interactive,
150+
}
151+
152+
impl From<ProfileConfigV1> for bashkit::ExecutionProfileName {
153+
fn from(value: ProfileConfigV1) -> Self {
154+
match value {
155+
ProfileConfigV1::Hardened => Self::Hardened,
156+
ProfileConfigV1::Standard => Self::Standard,
157+
ProfileConfigV1::Interactive => Self::Interactive,
158+
}
159+
}
160+
}
161+
141162
#[derive(Default, Deserialize)]
142163
#[serde(deny_unknown_fields)]
143164
struct LimitsConfigV1 {
@@ -180,7 +201,8 @@ fn build_from_config(config: ConfigV1) -> Result<Bash, ApiFailure> {
180201
));
181202
}
182203

183-
let mut limits = ExecutionLimits::default();
204+
let profile = bashkit::ExecutionProfile::named(config.profile.into());
205+
let mut limits = profile.execution_limits().clone();
184206
if let Some(value) = config.limits.timeout_ms {
185207
limits.timeout = Duration::from_millis(value);
186208
}
@@ -201,6 +223,7 @@ fn build_from_config(config: ConfigV1) -> Result<Bash, ApiFailure> {
201223
limits.capture_final_env = config.capture_final_env;
202224

203225
let mut builder = Bash::builder()
226+
.profile(profile)
204227
.limits(limits)
205228
.readonly_filesystem(config.readonly_filesystem);
206229
if let Some(cwd) = config.cwd {

crates/bashkit-capi/tests/abi.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ fn rejects_unknown_config_fields_and_versions() {
212212
br#"{"schema_version":2}"#.as_slice(),
213213
"unsupported configuration schema version 2",
214214
),
215+
(
216+
br#"{"schema_version":1,"profile":"permissive"}"#.as_slice(),
217+
"unknown variant",
218+
),
215219
] {
216220
let mut bash = ptr::null_mut();
217221
let mut error = ptr::null_mut();

crates/bashkit-cli/src/interactive.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,9 @@ fn test_bash() -> bashkit::Bash {
417417
.tty(0, true)
418418
.tty(1, true)
419419
.tty(2, true)
420-
.limits(bashkit::ExecutionLimits::cli())
421-
.session_limits(bashkit::SessionLimits::unlimited())
420+
.profile(bashkit::ExecutionProfile::named(
421+
bashkit::ExecutionProfileName::Interactive,
422+
))
422423
.build()
423424
}
424425

@@ -836,8 +837,9 @@ mod tests {
836837
.tty(0, true)
837838
.tty(1, true)
838839
.tty(2, true)
839-
.limits(bashkit::ExecutionLimits::cli())
840-
.session_limits(bashkit::SessionLimits::unlimited())
840+
.profile(bashkit::ExecutionProfile::named(
841+
bashkit::ExecutionProfileName::Interactive,
842+
))
841843
.on_exit(Box::new(move |event| {
842844
c.store(event.code, Ordering::Relaxed);
843845
bashkit::hooks::HookAction::Continue(event)

crates/bashkit-cli/src/main.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// opt-in for trusted scripts only; --no-http remains for symmetry with other
33
// feature toggles.
44
// Decision: keep one-shot CLI on a current-thread runtime for lower cold-start.
5-
// Decision: interactive mode uses relaxed execution limits (ExecutionLimits::cli())
5+
// Decision: interactive mode uses the typed Interactive execution profile
66
// because users have terminal control (Ctrl-C) and expect long-running sessions.
77
// One-shot command/script mode uses sandboxed defaults unless explicitly overridden
88
// by flags to avoid unbounded hangs for wrapper/automation usage.
@@ -23,7 +23,7 @@
2323
mod interactive;
2424

2525
use anyhow::{Context, Result, bail};
26-
use clap::Parser;
26+
use clap::{Parser, ValueEnum};
2727
use std::{
2828
ffi::OsStr,
2929
io::{IsTerminal, Write},
@@ -48,6 +48,10 @@ const REMOVED_MCP_COMMAND: &str = "mcp";
4848
#[command(name = "bashkit")]
4949
#[command(author, version, about, long_about = None)]
5050
struct Args {
51+
/// Resource-policy profile (defaults to standard, or interactive in REPL mode)
52+
#[arg(long, value_enum)]
53+
profile: Option<CliProfile>,
54+
5155
/// Execute the given command string
5256
#[arg(short = 'c')]
5357
command: Option<String>,
@@ -126,6 +130,23 @@ struct Args {
126130
no_stdin: bool,
127131
}
128132

133+
#[derive(Debug, Clone, Copy, ValueEnum)]
134+
enum CliProfile {
135+
Hardened,
136+
Standard,
137+
Interactive,
138+
}
139+
140+
impl From<CliProfile> for bashkit::ExecutionProfileName {
141+
fn from(value: CliProfile) -> Self {
142+
match value {
143+
CliProfile::Hardened => Self::Hardened,
144+
CliProfile::Standard => Self::Standard,
145+
CliProfile::Interactive => Self::Interactive,
146+
}
147+
}
148+
}
149+
129150
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130151
enum CliMode {
131152
Command,
@@ -138,7 +159,16 @@ fn build_bash(args: &Args, mode: CliMode) -> bashkit::Bash {
138159
}
139160

140161
fn configure_bash(args: &Args, mode: CliMode) -> bashkit::BashBuilder {
141-
let mut builder = bashkit::Bash::builder();
162+
let profile_name = args.profile.map(Into::into).unwrap_or_else(|| {
163+
if mode == CliMode::Interactive {
164+
bashkit::ExecutionProfileName::Interactive
165+
} else {
166+
bashkit::ExecutionProfileName::Standard
167+
}
168+
});
169+
let profile = bashkit::ExecutionProfile::named(profile_name);
170+
let mut limits = profile.execution_limits().clone();
171+
let mut builder = bashkit::Bash::builder().profile(profile);
142172

143173
if args.http_allow_all && !args.no_http {
144174
builder = builder.network(bashkit::NetworkAllowlist::allow_all());
@@ -165,12 +195,7 @@ fn configure_bash(args: &Args, mode: CliMode) -> bashkit::BashBuilder {
165195
builder = apply_real_mounts(builder, &args.mount_ro, &args.mount_rw);
166196
}
167197

168-
// Interactive mode uses relaxed limits; one-shot keeps sandboxed defaults.
169-
let mut limits = if mode == CliMode::Interactive {
170-
bashkit::ExecutionLimits::cli()
171-
} else {
172-
bashkit::ExecutionLimits::new()
173-
};
198+
// Flags are explicit per-field overrides on the selected profile.
174199
if let Some(v) = args.max_commands {
175200
limits = limits.max_commands(v);
176201
}
@@ -185,10 +210,6 @@ fn configure_bash(args: &Args, mode: CliMode) -> bashkit::BashBuilder {
185210
}
186211
builder = builder.limits(limits);
187212

188-
if mode == CliMode::Interactive {
189-
builder = builder.session_limits(bashkit::SessionLimits::unlimited());
190-
}
191-
192213
#[cfg(feature = "interactive")]
193214
if mode == CliMode::Interactive {
194215
builder = builder.tty(0, true).tty(1, true).tty(2, true);

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import test from "ava";
2-
import { Bash, BashTool, getVersion, BashError } from "../wrapper.js";
2+
import {
3+
Bash,
4+
BashTool,
5+
ExecutionProfile,
6+
getVersion,
7+
BashError,
8+
} from "../wrapper.js";
39

410
// ============================================================================
511
// Version
@@ -36,6 +42,15 @@ test("Bash: constructor with all options", (t) => {
3642
t.is(r.stdout.trim(), "u");
3743
});
3844

45+
test("Bash: hardened profile preserves isolated filesystem writes", (t) => {
46+
const bash = new Bash({ profile: ExecutionProfile.Hardened });
47+
const result = bash.executeSync(
48+
"printf profile > /tmp/profile; cat /tmp/profile",
49+
);
50+
t.is(result.exitCode, 0);
51+
t.is(result.stdout, "profile");
52+
});
53+
3954
// ============================================================================
4055
// Bash — basic execution
4156
// ============================================================================

crates/bashkit-js/src/lib.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use bashkit::{
2121
Bash as RustBash, BashTool as RustBashTool, Builtin, BuiltinContext, BuiltinRegistry,
2222
Credential, ExecResult as RustExecResult, ExecutionLimits, ExtFunctionResult,
2323
FileSystem as BashFileSystem, FileType, InMemoryFs, Metadata, MontyObject, NetworkAllowlist,
24-
OutputCallback, PosixFs, PythonExternalFnHandler, PythonLimits, RealFs, RealFsMode,
24+
OutputCallback, PosixFs, PythonExternalFnHandler, RealFs, RealFsMode,
2525
ScriptedTool as RustScriptedTool, SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs,
2626
ToolDef, ToolRequest, async_trait,
2727
};
@@ -1283,6 +1283,8 @@ fn apply_network_options(
12831283
/// Options for creating a Bash or BashTool instance.
12841284
#[napi(object)]
12851285
pub struct BashOptions {
1286+
/// Named resource-policy baseline. Individual options override its fields.
1287+
pub profile: Option<ExecutionProfileName>,
12861288
pub username: Option<String>,
12871289
pub hostname: Option<String>,
12881290
/// Initial working directory for the shell (mirrors `Bash::builder().cwd()`).
@@ -1337,6 +1339,25 @@ pub struct BashOptions {
13371339
pub network: Option<NetworkOptions>,
13381340
}
13391341

1342+
/// Typed named execution-profile selector.
1343+
#[napi(string_enum)]
1344+
#[derive(Debug, Clone, Copy)]
1345+
pub enum ExecutionProfileName {
1346+
Hardened,
1347+
Standard,
1348+
Interactive,
1349+
}
1350+
1351+
impl From<ExecutionProfileName> for bashkit::ExecutionProfileName {
1352+
fn from(value: ExecutionProfileName) -> Self {
1353+
match value {
1354+
ExecutionProfileName::Hardened => Self::Hardened,
1355+
ExecutionProfileName::Standard => Self::Standard,
1356+
ExecutionProfileName::Interactive => Self::Interactive,
1357+
}
1358+
}
1359+
}
1360+
13401361
/// One simple command found by `analyze()`.
13411362
///
13421363
/// `name` and each entry of `args` are `null` when the word is not fully
@@ -1448,6 +1469,7 @@ pub struct SnapshotOptions {
14481469
fn default_opts() -> BashOptions {
14491470
BashOptions {
14501471
username: None,
1472+
profile: None,
14511473
hostname: None,
14521474
cwd: None,
14531475
env: None,
@@ -1519,6 +1541,7 @@ struct SharedState {
15191541
in_sync_execute_depth: Arc<AtomicUsize>,
15201542
async_execute_semaphore: Arc<Semaphore>,
15211543
username: Option<String>,
1544+
profile: Option<ExecutionProfileName>,
15221545
hostname: Option<String>,
15231546
cwd: Option<String>,
15241547
env: Option<HashMap<String, String>>,
@@ -3410,7 +3433,7 @@ impl ScriptedTool {
34103433

34113434
/// Build `ExecutionLimits` from the limit fields stored in `SharedState`.
34123435
fn build_limits(state: &SharedState) -> ExecutionLimits {
3413-
let mut limits = ExecutionLimits::new();
3436+
let mut limits = core_profile(state).execution_limits().clone();
34143437
if let Some(v) = state.max_commands {
34153438
limits = limits.max_commands(v as usize);
34163439
}
@@ -3451,7 +3474,7 @@ fn build_limits(state: &SharedState) -> ExecutionLimits {
34513474
}
34523475

34533476
fn derive_sqlite_limits(state: &SharedState) -> bashkit::SqliteLimits {
3454-
let mut limits = bashkit::SqliteLimits::default();
3477+
let mut limits = core_profile(state).sqlite_limits().clone();
34553478
if let Some(ms) = state.timeout_ms {
34563479
limits = limits.max_duration(std::time::Duration::from_millis(u64::from(ms)));
34573480
}
@@ -3464,8 +3487,18 @@ fn derive_sqlite_limits(state: &SharedState) -> bashkit::SqliteLimits {
34643487
limits
34653488
}
34663489

3490+
fn core_profile(state: &SharedState) -> bashkit::ExecutionProfile {
3491+
bashkit::ExecutionProfile::named(
3492+
state
3493+
.profile
3494+
.map(Into::into)
3495+
.unwrap_or(bashkit::ExecutionProfileName::Standard),
3496+
)
3497+
}
3498+
34673499
fn build_bash_from_state(state: &SharedState) -> RustBash {
3468-
let mut builder = RustBash::builder();
3500+
let profile = core_profile(state);
3501+
let mut builder = RustBash::builder().profile(profile.clone());
34693502

34703503
if let Some(ref u) = state.username {
34713504
builder = builder.username(u);
@@ -3525,7 +3558,7 @@ fn build_bash_from_state(state: &SharedState) -> RustBash {
35253558
Box::pin(async move { h(name, args, kwargs).await })
35263559
});
35273560
builder = builder.python_with_external_handler(
3528-
PythonLimits::default(),
3561+
profile.python_limits().clone(),
35293562
fn_names,
35303563
python_handler,
35313564
);
@@ -3587,6 +3620,7 @@ fn shared_state_from_opts(
35873620
in_sync_execute_depth: Arc::new(AtomicUsize::new(0)),
35883621
async_execute_semaphore: Arc::new(Semaphore::new(MAX_PENDING_ASYNC_EXECUTIONS)),
35893622
username: opts.username.clone(),
3623+
profile: opts.profile,
35903624
hostname: opts.hostname.clone(),
35913625
cwd: opts.cwd.clone(),
35923626
env: opts.env.clone(),
@@ -3640,6 +3674,7 @@ fn shared_state_from_opts(
36403674
in_sync_execute_depth: tmp.in_sync_execute_depth,
36413675
async_execute_semaphore: Arc::new(Semaphore::new(MAX_PENDING_ASYNC_EXECUTIONS)),
36423676
username: opts.username,
3677+
profile: opts.profile,
36433678
hostname: opts.hostname,
36443679
cwd: opts.cwd,
36453680
env: opts.env,

0 commit comments

Comments
 (0)