Wire NetworkConfig through Bash + curl + interpreter (closes #5) - #7
Merged
Merged
Conversation
…uest aiohttp injects `Accept-Encoding: gzip, deflate` on every request. Combined with `auto_decompress=False` (which lets `_read_limited_body` enforce the size cap on wire bytes), plain `curl` — which never opted into compression — received raw gzip bytes the curl layer correctly refused to decompress (matching real curl, which only decompresses under --compressed), surfacing as binary garbage. Real curl sends no `Accept-Encoding` unless `--compressed` is passed. Pass `skip_auto_headers=["Accept-Encoding"]` so the transport only sends an Accept-Encoding the caller set explicitly: curl URL -> no AE sent -> identity -> clean text curl --compressed URL -> curl sets gzip,deflate -> decompressed curl -H 'Accept-Encoding: gzip' -> sent as-is -> raw bytes (as real curl) `skip_auto_headers` only suppresses the auto-generated header; an explicitly supplied one is still sent, so the --compressed path is unaffected. Adds regression tests covering the plain and --compressed paths against a server that gzips only when the client advertises gzip.
CI ran only `tests/test_commands/`, so `tests/test_network.py` (the network/curl integration suite, including the curl compression regression tests) was never executed by the Test or Release workflows — a transport regression could merge or ship green. Add `tests/test_network.py` to both pytest invocations so the network suite is gated.
Contributor
Author
|
@dbreunig fixed curl / gzip options and fixed issue with my initial tests can run during CI |
There was a problem hiding this comment.
Pull request overview
This PR makes NetworkConfig (and/or a custom fetch) actually enable network operations by wiring a secure fetch function through Bash → Interpreter → CommandContext → curl, and adds a new just_bash.network module implementing allow-list/method/redirect/timeout/size/private-range controls.
Changes:
- Add an
aiohttp-backed default secure fetch (make_default_fetch) with allow-list validation, method restrictions, redirect re-checks, size/time limits, and optional private-range blocking + DNS pinning. - Register
curlonly whennetworkorfetchis configured; passfetchthrough interpreter execution contexts so commands can use it. - Add comprehensive network tests and document the new network/fetch usage; include the new tests in CI workflows.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/just_bash/bash.py |
Creates/threads fetch based on NetworkConfig and conditionally registers network commands. |
src/just_bash/interpreter/interpreter.py |
Threads fetch through interpreter instances and into CommandContext. |
src/just_bash/commands/curl/curl.py |
Fixes stdout rendering when fetch returns byte bodies (prevents str+bytes issues). |
src/just_bash/types.py |
Extends public types with AllowedUrl, RequestTransform, SecureFetch, and expands NetworkConfig fields. |
src/just_bash/network/__init__.py |
New network security + default fetch implementation (allow-list, redirects, private-range blocking, limits). |
src/just_bash/__init__.py |
Re-exports new network-related types. |
tests/test_network.py |
Adds tests for allow-list validation/matching, redirects, limits, private ranges, header transforms, and compression semantics. |
README.md |
Documents how to enable network access and use fetch/transforms safely. |
.github/workflows/test.yml |
Runs the new network test file in CI. |
.github/workflows/release.yml |
Runs the new network test file in release workflow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Four items from the Copilot review of the network/allow-list code (a port of Vercel's TS just-bash), all in the ported allow-list/private-range surface: - Widen `NetworkConfig.allowed_url_prefixes` and `_validate_allow_list` from `list[str | AllowedUrl]` to `list[str | AllowedUrl | dict[str, Any]]`. The runtime already accepts dict-shaped entries (`_entry_url`, `_validate_allow_list`, `firewall_headers` all branch on `isinstance(entry, dict)`); the annotation just didn't model the third shape carried over from the TS object literals. - Hoist the private-range CIDR tables out of `_is_private_ipv4`/`_is_private_ipv6` into module-level constants (`_PRIVATE_IPV4_NETWORKS`, `_PRIVATE_IPV6_NETWORKS`, `_SIXTOFOUR_NETWORK`). They were rebuilt on every call — and this is the hot path for `deny_private_ranges=True` (hostname + each resolved address). - Annotate `firewall_headers`' local `transforms` as `Sequence[...]` (covariant) instead of `list[...]` (invariant), clearing the assignment-type mismatch between the dict and `AllowedUrl.transform` branches. Behavior unchanged; 2297 tests pass (incl. all private-range tests). NOT included (deliberately scoped out): the DNS-resolver typing cluster (`_PinnedResolver.resolve` not conforming to aiohttp's `List[ResolveResult]`, the `_resolve_host` tuple type, `aiohttp.abc.AbstractResolver` access). Giving pyright the real base type surfaces a genuine interface-conformance question on security-sensitive rebinding-protection code that deserves its own grounded change.
The remaining Copilot-review-adjacent items in the resolver, all from the original wiring: `_PinnedResolver.resolve` returned `list[dict[str, Any]]` where aiohttp's `AbstractResolver.resolve` requires `List[ResolveResult]`, and `_resolve_host` built a `tuple[str | int, AddressFamily]` dedup key that didn't match its `set[tuple[str, int]]`. - Import `AbstractResolver`/`ResolveResult` from `aiohttp.abc` and subclass `AbstractResolver` directly (clears the `aiohttp.abc` attribute-access warning and lets pyright actually check the override). - Type `_resolve_host`, `check_allowed`, and `_PinnedResolver` records/return as `ResolveResult` (the record dicts already carry exactly its keys). - Coerce `sockaddr[0]` to `str` so the dedup key is `tuple[str, AddressFamily]`. pyright: 0 errors/0 warnings on the module. Behavior unchanged; 2297 tests pass incl. all private-range/rebinding tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Upstream defines
NetworkConfigandCommandContext.fetchbut they never reachBash,Interpreter, or thecurlcommand, so passingnetwork=NetworkConfig(...)is a no-op (see #5 — "command not found\nIs shown even when a network config is provided"). This PR wires the plumbing through so the documented surface actually executes network calls.Components
Touches
src/just_bash/__init__.py,bash.py,commands/curl/curl.py,interpreter/interpreter.py,types.py, and addssrc/just_bash/network/__init__.py+tests/test_network.py.NetworkConfigcurlcommand registered only whennetwork/fetchis configured (no-op fallback otherwise — preserves existing behavior)startsWith)max_redirects,timeout_ms,max_response_sizeenforceddeny_private_rangeswith lexical IPv4/IPv6 checks + DNS-resolution recheck, DNS-pinnedaiohttp.TCPConnectorto defeat rebinding between preflight and connectionRequestTransform) applied at the fetch boundary so credentials never enter the sandboxcurl -owritesTS-parity hardening
Ported from
vercel-labs/just-bashsrc/network/allow-list.ts:validateAllowListfrommake_default_fetchunlessdangerously_allow_full_internet_access=True— rejects malformed entries, missing scheme/host, non-http(s) schemes, query strings and fragments, and ambiguous path separators (\,%2f,%5c)URL.origin: lowercase scheme/host, strip default ports (:80,:443), exact non-default ports100.64.0.0/10(whichipaddress.IPv4Address.is_privatemisses in 3.11), benchmarking198.18.0.0/15, IETF/TEST-NET, and reserved240/42130706433and0x7f.0.0.1style numeric forms (socket.getaddrinfocatches them at resolve time but the lexical pass needs parity with the TS sibling)::,::1,fe80::/10,fc00::/7,::ffff:IPv4-mapped,2001:db8::/32, NAT6464:ff9b::/96, NAT64-local64:ff9b:1::/48, 6to42002::/16with embedded-v4 recheckContent-Lengthparsing (malformed → ignored, rely on streamed body-size enforcement instead of bubblingValueError)Compression handling (Accept-Encoding)
aiohttpauto-advertisesAccept-Encoding: gzip, deflateon every request. Combined with the deliberateauto_decompress=False(which lets_read_limited_bodyenforce the size cap on wire bytes), plaincurl— which never opted into compression — received raw gzip bytes that the curl layer correctly refused to decompress (real curl only decompresses under--compressed), surfacing as binary garbage.Fixed by passing
skip_auto_headers=["Accept-Encoding"]so the transport sends only anAccept-Encodingthe caller set explicitly, restoring real-curl semantics:Accept-Encodingsentcurl URLcurl --compressed URLgzip, deflate(set by curl layer)curl -H 'Accept-Encoding: gzip' URLgzipskip_auto_headerssuppresses only the auto-generated header; an explicitly supplied one still goes through, so--compressedis unaffected. The size cap is preserved (and improved — plain requests now measure true/identity size).Tests
tests/test_network.pycovers allow-list validation + matching, allowed-methods, redirects with per-hop checks, timeout, response size, private ranges, content-length parsing, header transforms, and byte-preserving response body. It also covers compression negotiation:test_plain_curl_does_not_advertise_compression— plaincurlsends no gzip request and returns clean texttest_compressed_flag_still_negotiates_and_decompresses—--compressednegotiates gzip and decompressesWhy upstream this now
This work has been carrying as a fork (
ajram23/just-bash-pytagv0.1.16.post1) since May 2026 to unblock real agent network operations (thecurlcommand in a sandboxed agent fetch loop is the load-bearing use case). The fork'sORBIT_FORK.mdexplicitly tracks the retirement criterion: "Delete the fork and switch back to upstream PyPI just-bash once dbreunig/just-bash-py releases a version that wires NetworkConfig through Bash and ships the TS-parity allow-list / private-range checks." This PR is that release candidate.Closes #5.