Skip to content

feat(python): expose outbound HTTP/network config on Bash/BashTool #1348

@oliverlambson

Description

@oliverlambson

Rust core Bash::builder() already supports outbound HTTP config via network, credential, credential_placeholder, http_handler, before_http, after_http, and bot_auth. Python bindings expose none of that today, and the Python extension currently compiles bashkit without http_client or bot-auth.

There's been recent Python parity work for snapshotting, direct VFS helpers, and shell state. Network/HTTP config is still a clear gap.

Summary

  • Enable Rust HTTP support in the Python extension.
  • Add a single network= kwarg on both Bash(...) and BashTool(...).
  • The full Python surface should cover Rust parity for allowlists, allow_all, credential injection, credential placeholders, optional transport hooks, and bot-auth.
  • Network should stay disabled by default, preserve config across reset(), and be reattachable via from_snapshot(...).

Proposed API

Expose one structured network= kwarg on both Bash(...) and BashTool(...).

bash = Bash(
    network={
        "allow": ["https://api.github.com", "https://api.openai.com/v1"],
        "block_private_ips": True,
        "credentials": [{"pattern": "https://api.github.com", "kind": "bearer", "token": "ghp_test"}],
        "credential_placeholders": [{"env": "OPENAI_API_KEY", "pattern": "https://api.openai.com", "kind": "bearer", "token": "sk-real"}],
        "before_http": [audit_request],
        "after_http": [audit_response],
        "http_handler": mock_transport,
        "bot_auth": {"seed": "base64url-ed25519-seed", "agent_fqdn": "bot.example.com", "validity_secs": 300},
    },
)

tool = BashTool(network={"allow_all": True})

Rust-parity semantics should be:

  • omitted network means network disabled
  • network={"allow": []} means explicit empty allowlist and blocks all URLs
  • network={"allow_all": True} maps to NetworkAllowlist::allow_all()
  • wildcard patterns like "*" are not part of allowlist semantics
  • block_private_ips defaults to True
  • injected credentials overwrite conflicting script-provided headers
  • http_handler runs after allowlist and private-IP checks so the security boundary stays in bashkit
Detailed typing sketch and design notes

Runtime surface should stay plain dicts + callables. Python stubs can expose TypedDict / Protocol aliases for better typing.

from typing import Awaitable, Literal, Optional, Protocol, TypedDict, Union
from typing_extensions import NotRequired

HeaderList = list[tuple[str, str]]


class HttpResponse(TypedDict):
    status: int
    headers: HeaderList
    body: bytes


class HttpRequestEvent(TypedDict):
    method: str
    url: str
    headers: HeaderList


class HttpResponseEvent(TypedDict):
    url: str
    status: int
    headers: HeaderList


class CancelHttpRequest(TypedDict):
    action: Literal["cancel"]
    reason: str


class HttpHandler(Protocol):
    def __call__(
        self,
        method: str,
        url: str,
        body: Optional[bytes],
        headers: HeaderList,
    ) -> Union[HttpResponse, Awaitable[HttpResponse]]: ...


class BeforeHttpHook(Protocol):
    def __call__(
        self,
        event: HttpRequestEvent,
    ) -> Union[
        HttpRequestEvent,
        CancelHttpRequest,
        Awaitable[Union[HttpRequestEvent, CancelHttpRequest]],
    ]: ...


class AfterHttpHook(Protocol):
    def __call__(
        self,
        event: HttpResponseEvent,
    ) -> Union[
        None,
        HttpResponseEvent,
        Awaitable[Union[None, HttpResponseEvent]],
    ]: ...


class BearerCredentialInjection(TypedDict):
    pattern: str
    kind: Literal["bearer"]
    token: str


class HeaderCredentialInjection(TypedDict):
    pattern: str
    kind: Literal["header"]
    name: str
    value: str


class HeadersCredentialInjection(TypedDict):
    pattern: str
    kind: Literal["headers"]
    headers: HeaderList


CredentialInjection = Union[
    BearerCredentialInjection,
    HeaderCredentialInjection,
    HeadersCredentialInjection,
]


class BearerCredentialPlaceholder(TypedDict):
    env: str
    pattern: str
    kind: Literal["bearer"]
    token: str


class HeaderCredentialPlaceholder(TypedDict):
    env: str
    pattern: str
    kind: Literal["header"]
    name: str
    value: str


class HeadersCredentialPlaceholder(TypedDict):
    env: str
    pattern: str
    kind: Literal["headers"]
    headers: HeaderList


CredentialPlaceholder = Union[
    BearerCredentialPlaceholder,
    HeaderCredentialPlaceholder,
    HeadersCredentialPlaceholder,
]


class BotAuthConfig(TypedDict):
    seed: Union[str, bytes]
    agent_fqdn: NotRequired[str]
    validity_secs: NotRequired[int]


class AllowlistNetworkConfig(TypedDict):
    allow: list[str]
    block_private_ips: NotRequired[bool]
    credentials: NotRequired[list[CredentialInjection]]
    credential_placeholders: NotRequired[list[CredentialPlaceholder]]
    http_handler: NotRequired[HttpHandler]
    before_http: NotRequired[list[BeforeHttpHook]]
    after_http: NotRequired[list[AfterHttpHook]]
    bot_auth: NotRequired[BotAuthConfig]


class AllowAllNetworkConfig(TypedDict):
    allow_all: Literal[True]
    block_private_ips: NotRequired[bool]
    credentials: NotRequired[list[CredentialInjection]]
    credential_placeholders: NotRequired[list[CredentialPlaceholder]]
    http_handler: NotRequired[HttpHandler]
    before_http: NotRequired[list[BeforeHttpHook]]
    after_http: NotRequired[list[AfterHttpHook]]
    bot_auth: NotRequired[BotAuthConfig]


NetworkConfig = Union[AllowlistNetworkConfig, AllowAllNetworkConfig]

Additional notes:

  • Callback surfaces should follow existing Python callback conventions used by custom_builtins and ScriptedTool.add_tool.
  • Sync and async callables should both work.
  • Callback ordering should follow list order.
  • Callbacks should not be allowed to re-enter the same Bash / BashTool instance via live execution methods.
  • Snapshots should not serialize live callbacks or secret material; those should be reattached via constructor / from_snapshot(...) kwargs.
  • Today the Python crate omits http_client, exposes no network= kwarg, and reset() / from_snapshot() have no network config to preserve.
  • crates/bashkit-python/README.md currently advertises curl, but the Python package cannot enable allowlisted HTTP today.

Recommended rollout

  1. Enable http_client and ship network={"allow": ...} / network={"allow_all": True} with block_private_ips, reset(), and from_snapshot() support.
  2. Add credentials and credential_placeholders.
  3. Add Python callback support for http_handler, before_http, and after_http.
  4. Add bot_auth support and enable the corresponding Rust feature for the Python crate.

The issue should describe the full end-state API above even if implementation lands in that order.

Acceptance criteria

  • Python crate builds bashkit with http_client
  • Bash(...) accepts network=...
  • BashTool(...) accepts the same network=... runtime config
  • Python exposes explicit allow-all mode corresponding to Rust NetworkAllowlist::allow_all()
  • Python preserves Rust empty-allowlist behavior (network={"allow": []} blocks all, distinct from omitted network)
  • Network remains disabled by default
  • curl / wget / http work from Python when the target URL is allowlisted
  • Blocked hosts still fail
  • Private IP blocking remains default-on and test-covered
  • Credential injection is supported from Python
  • Credential placeholder injection is supported from Python
  • Python exposes callback parity for http_handler, before_http, and after_http
  • Python exposes bot_auth config parity
  • reset() preserves original network config on both Bash and BashTool
  • from_snapshot() accepts network=... and restores a configured instance
  • Python stubs and README document the new config
  • Tests use local/mock transport only, with no public network dependency

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions