Skip to content

Wire NetworkConfig through Bash + curl + interpreter (closes #5) - #7

Merged
dbreunig merged 8 commits into
dbreunig:mainfrom
ajram23:feat/network-curl-wiring
Jun 4, 2026
Merged

Wire NetworkConfig through Bash + curl + interpreter (closes #5)#7
dbreunig merged 8 commits into
dbreunig:mainfrom
ajram23:feat/network-curl-wiring

Conversation

@ajram23

@ajram23 ajram23 commented May 29, 2026

Copy link
Copy Markdown
Contributor

What this does

Upstream defines NetworkConfig and CommandContext.fetch but they never reach Bash, Interpreter, or the curl command, so passing network=NetworkConfig(...) is a no-op (see #5"command not found\n Is 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 adds src/just_bash/network/__init__.py + tests/test_network.py.

  • aiohttp-backed default fetch built from NetworkConfig
  • curl command registered only when network/fetch is configured (no-op fallback otherwise — preserves existing behavior)
  • URL allow-list with origin + path-prefix matching using segment-boundary checks (not raw startsWith)
  • Allowed-methods enforcement; manual redirect handling with per-hop allow-list re-check
  • max_redirects, timeout_ms, max_response_size enforced
  • deny_private_ranges with lexical IPv4/IPv6 checks + DNS-resolution recheck, DNS-pinned aiohttp.TCPConnector to defeat rebinding between preflight and connection
  • Header transforms (RequestTransform) applied at the fetch boundary so credentials never enter the sandbox
  • Byte-preserving response body for curl -o writes

TS-parity hardening

Ported from vercel-labs/just-bash src/network/allow-list.ts:

  • Fail-fast validateAllowList from make_default_fetch unless dangerously_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)
  • Origin matching normalized like TS URL.origin: lowercase scheme/host, strip default ports (:80, :443), exact non-default ports
  • Explicit IPv4 private-range table including CGNAT 100.64.0.0/10 (which ipaddress.IPv4Address.is_private misses in 3.11), benchmarking 198.18.0.0/15, IETF/TEST-NET, and reserved 240/4
  • Lexical IPv4 parser accepting 2130706433 and 0x7f.0.0.1 style numeric forms (socket.getaddrinfo catches them at resolve time but the lexical pass needs parity with the TS sibling)
  • Explicit IPv6 checks: ::, ::1, fe80::/10, fc00::/7, ::ffff: IPv4-mapped, 2001:db8::/32, NAT64 64:ff9b::/96, NAT64-local 64:ff9b:1::/48, 6to4 2002::/16 with embedded-v4 recheck
  • Defensive Content-Length parsing (malformed → ignored, rely on streamed body-size enforcement instead of bubbling ValueError)

Compression handling (Accept-Encoding)

aiohttp auto-advertises Accept-Encoding: gzip, deflate on every request. Combined with the deliberate 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 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 an Accept-Encoding the caller set explicitly, restoring real-curl semantics:

Invocation Accept-Encoding sent response output
curl URL (none) identity clean text
curl --compressed URL gzip, deflate (set by curl layer) gzip decompressed
curl -H 'Accept-Encoding: gzip' URL gzip gzip raw bytes (as real curl)

skip_auto_headers suppresses only the auto-generated header; an explicitly supplied one still goes through, so --compressed is unaffected. The size cap is preserved (and improved — plain requests now measure true/identity size).

Tests

tests/test_network.py covers 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 — plain curl sends no gzip request and returns clean text
  • test_compressed_flag_still_negotiates_and_decompresses--compressed negotiates gzip and decompresses

Why upstream this now

This work has been carrying as a fork (ajram23/just-bash-py tag v0.1.16.post1) since May 2026 to unblock real agent network operations (the curl command in a sandboxed agent fetch loop is the load-bearing use case). The fork's ORBIT_FORK.md explicitly 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.

ajram23 added 6 commits May 28, 2026 22:17
…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.
@ajram23

ajram23 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@dbreunig fixed curl / gzip options and fixed issue with my initial tests can run during CI

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 curl only when network or fetch is configured; pass fetch through 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.

Comment thread src/just_bash/types.py Outdated
Comment thread src/just_bash/network/__init__.py Outdated
Comment thread src/just_bash/network/__init__.py Outdated
Comment thread src/just_bash/network/__init__.py
ajram23 added 2 commits June 2, 2026 22:17
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.
@dbreunig
dbreunig merged commit f9b7079 into dbreunig:main Jun 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"command not found\n" Is shown even when a network config is provided.

3 participants