$_ 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}};

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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");
# Ok(())
# }

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}};

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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);
# Ok(())
# }

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};

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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"));
# Ok(())
# }

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}};

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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);
# Ok(())
# }

after_tool — Audit Command Results

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

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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);
# Ok(())
# }

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};

# fn main() {
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};

# fn main() {
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}};

# fn main() {
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};

# fn main() {
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}};

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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");
# Ok(())
# }

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

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

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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);
# Ok(())
# }

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};

# fn main() {
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();
# }