bashkit

Hooks#

Bashkit provides an interceptor hook system that lets you observe, modify, or cancel operations at key points in the execution pipeline. Hooks are registered at build time via BashBuilder and are immutable after construction.

See also:

Quick Start#

use bashkit::{Bash, hooks::{HookAction, ExecInput}};

let mut bash = Bash::builder()
    .before_exec(Box::new(|input: ExecInput| {
        println!("about to run: {}", input.script);
        HookAction::Continue(input)
    }))
    .build();

let result = bash.exec("echo hello").await?;
assert_eq!(result.stdout, "hello\n");

How Hooks Work#

Every hook is an interceptor — a closure that receives owned data and must return a HookAction:

  • HookAction::Continue(value) — proceed with the (possibly modified) value
  • HookAction::Cancel(reason) — abort the operation

Multiple hooks of the same type run in registration order. If any hook returns Cancel, later hooks are skipped and the operation is aborted.

Hooks have zero overhead when none are registered — the interpreter checks Vec::is_empty() and skips the hook path entirely.

Hook Types#

HookFiresCan modifyCan cancel
before_execBefore script executionScript textYes
after_execAfter script executionstdout, stderr, exit codeNo*
before_toolBefore a builtin command runsTool name, argsYes
after_toolAfter a builtin command completesTool name, stdout, exit codeNo*
on_exitWhen exit builtin runsExit codeYes
on_errorOn interpreter errorError messageNo*
before_httpBefore HTTP request (after allowlist)URL, method, headersYes
after_httpAfter HTTP response receivedURL, status, headersNo*

*These hooks receive Continue/Cancel for API consistency, but cancelling an already-completed operation is a no-op in practice.

Execution Hooks#

before_exec — Modify or Block Scripts#

Fires before each bash.exec() call. The hook receives an ExecInput with the script text and can rewrite or cancel it.

use bashkit::{Bash, hooks::{HookAction, ExecInput}};

let mut bash = Bash::builder()
    .before_exec(Box::new(|mut input: ExecInput| {
        // Block dangerous commands
        if input.script.contains("rm -rf") {
            return HookAction::Cancel("destructive command blocked".into());
        }
        // Rewrite scripts on the fly
        input.script = input.script.replace("world", "hooks");
        HookAction::Continue(input)
    }))
    .build();

let result = bash.exec("echo hello world").await?;
assert_eq!(result.stdout, "hello hooks\n");

// Cancelled scripts return exit code 1
let result = bash.exec("rm -rf /").await?;
assert_eq!(result.exit_code, 1);

after_exec — Observe Results#

Fires after script execution completes. Useful for logging, metrics, or post-processing.

use bashkit::{Bash, hooks::{HookAction, ExecOutput}};
use std::sync::{Arc, Mutex};

let log = Arc::new(Mutex::new(Vec::new()));
let log_clone = log.clone();

let mut bash = Bash::builder()
    .after_exec(Box::new(move |output: ExecOutput| {
        log_clone.lock().unwrap().push(format!(
            "[exit {}] {}",
            output.exit_code,
            output.script,
        ));
        HookAction::Continue(output)
    }))
    .build();

bash.exec("echo first").await?;
bash.exec("echo second").await?;

let entries = log.lock().unwrap();
assert_eq!(entries.len(), 2);
assert!(entries[0].contains("first"));

Tool Hooks#

Tool hooks fire around registered builtin commands (e.g. echo, cat, grep). They do not fire for shell-level special builtins like declare, local, or export.

before_tool — Intercept Commands#

use bashkit::{Bash, hooks::{HookAction, ToolEvent}};

let mut bash = Bash::builder()
    .before_tool(Box::new(|event: ToolEvent| {
        // Block specific commands
        if event.name == "curl" {
            return HookAction::Cancel("curl is disabled".into());
        }
        HookAction::Continue(event)
    }))
    .build();

let result = bash.exec("echo allowed").await?;
assert_eq!(result.exit_code, 0);

after_tool — Audit Command Results#

use bashkit::{Bash, hooks::{HookAction, ToolResult}};
use std::sync::{Arc, Mutex};

let results = Arc::new(Mutex::new(Vec::new()));
let results_clone = results.clone();

let mut bash = Bash::builder()
    .after_tool(Box::new(move |result: ToolResult| {
        results_clone.lock().unwrap().push((
            result.name.clone(),
            result.exit_code,
        ));
        HookAction::Continue(result)
    }))
    .build();

bash.exec("echo hello").await?;

let captured = results.lock().unwrap();
assert_eq!(captured[0].0, "echo");
assert_eq!(captured[0].1, 0);

Lifecycle Hooks#

on_exit — Handle Script Exit#

Fires when the exit builtin is called. Can modify the exit code or prevent the exit entirely.

use bashkit::{Bash, hooks::{HookAction, ExitEvent}};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

let exited = Arc::new(AtomicBool::new(false));
let flag = exited.clone();

let bash = Bash::builder()
    .on_exit(Box::new(move |event: ExitEvent| {
        flag.store(true, Ordering::Relaxed);
        HookAction::Continue(event)
    }))
    .build();

on_error — Handle Errors#

Fires when the interpreter encounters an error (parse errors, runtime errors).

use bashkit::{Bash, hooks::{HookAction, ErrorEvent}};
use std::sync::{Arc, Mutex};

let errors = Arc::new(Mutex::new(Vec::new()));
let errors_clone = errors.clone();

let bash = Bash::builder()
    .on_error(Box::new(move |event: ErrorEvent| {
        errors_clone.lock().unwrap().push(event.message.clone());
        HookAction::Continue(event)
    }))
    .build();

HTTP Hooks#

HTTP hooks require the http_client feature (enabled by default). They fire around HTTP requests made by curl, wget, and http builtins.

HTTP hooks fire after the NetworkAllowlist check, so the security boundary stays in bashkit — hooks cannot bypass the allowlist.

before_http — Filter or Modify Requests#

use bashkit::{Bash, NetworkAllowlist, hooks::{HookAction, HttpRequestEvent}};

let bash = Bash::builder()
    .network(NetworkAllowlist::allow_all())
    .before_http(Box::new(|mut req: HttpRequestEvent| {
        // Add a custom header to all requests
        req.headers.push(("X-Source".into(), "bashkit".into()));

        // Block requests to certain domains
        if req.url.contains("blocked.example.com") {
            return HookAction::Cancel("blocked by policy".into());
        }

        HookAction::Continue(req)
    }))
    .build();

after_http — Observe Responses#

use bashkit::{Bash, NetworkAllowlist, hooks::{HookAction, HttpResponseEvent}};
use std::sync::{Arc, Mutex};

let responses = Arc::new(Mutex::new(Vec::new()));
let responses_clone = responses.clone();

let bash = Bash::builder()
    .network(NetworkAllowlist::allow_all())
    .after_http(Box::new(move |resp: HttpResponseEvent| {
        responses_clone.lock().unwrap().push((
            resp.url.clone(),
            resp.status,
        ));
        HookAction::Continue(resp)
    }))
    .build();

Chaining Multiple Hooks#

Multiple hooks of the same type run in registration order. Each hook receives the output of the previous one:

use bashkit::{Bash, hooks::{HookAction, ExecInput}};

let mut bash = Bash::builder()
    .before_exec(Box::new(|mut input: ExecInput| {
        input.script = input.script.replace("world", "hooks");
        HookAction::Continue(input)
    }))
    .before_exec(Box::new(|mut input: ExecInput| {
        input.script = input.script.replace("hello", "greetings");
        HookAction::Continue(input)
    }))
    .build();

let result = bash.exec("echo hello world").await?;
assert_eq!(result.stdout, "greetings hooks\n");

If any hook in the chain returns Cancel, the remaining hooks are skipped:

use bashkit::{Bash, hooks::{HookAction, ExecInput}};

let mut bash = Bash::builder()
    .before_exec(Box::new(|_input: ExecInput| {
        HookAction::Cancel("first hook cancelled".into())
    }))
    .before_exec(Box::new(|input: ExecInput| {
        // This hook never runs
        HookAction::Continue(input)
    }))
    .build();

let result = bash.exec("echo never runs").await?;
assert_eq!(result.exit_code, 1);

Event Payloads#

PayloadFields
ExecInputscript: String
ExecOutputscript: String, stdout: String, stderr: String, exit_code: i32
ToolEventname: String, args: Vec<String>
ToolResultname: String, stdout: String, exit_code: i32
ExitEventcode: i32
ErrorEventmessage: String
HttpRequestEventmethod: String, url: String, headers: Vec<(String, String)>
HttpResponseEventurl: String, status: u16, headers: Vec<(String, String)>

Thread Safety#

Hook closures must be Send + Sync. Use Arc and atomic types for shared state between hooks and the caller:

use bashkit::{Bash, hooks::{HookAction, ExecOutput}};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

let exec_count = Arc::new(AtomicU64::new(0));
let counter = exec_count.clone();

let bash = Bash::builder()
    .after_exec(Box::new(move |output: ExecOutput| {
        counter.fetch_add(1, Ordering::Relaxed);
        HookAction::Continue(output)
    }))
    .build();