Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## v1.2.1 - 2026-08-15

- Move GitHub proxy-capability renewal into the long-lived host proxy daemon so
registered running boxes refresh every seven hours without an open Devbox
terminal, guest restart, or `.devbox.toml`/image resolution. Add
`devbox proxy refresh` for an immediate manifest-independent refresh.
- Route Homebrew's public `gh` path through the managed proxy wrapper under
`--proxy`, keeping the real binary as a private wrapper dependency and
restoring the normal Homebrew link under `--no-auth`.

## v1.2.0 - 2026-08-15

- Persist resumable Claude Code, Codex, OpenCode, Pi, and Stado session state
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ brew install foobarto/tap/devbox

Installs `devbox` and `devbox-ai-proxy` on your `PATH`. The current stable
GitHub release is
[`v1.2.0`](https://github.com/foobarto/devbox/releases/tag/v1.2.0); source
[`v1.2.1`](https://github.com/foobarto/devbox/releases/tag/v1.2.1); source
archives are available from that release. Config lives under `~/.config/devbox/`
(or `$XDG_CONFIG_HOME/devbox`).

Expand Down Expand Up @@ -243,7 +243,8 @@ enters the box. See
[`proxy/README.md`](proxy/README.md) for the full explanation. `--proxy` is the
recommended default for disposable boxes, and it auto-starts the host proxy
(once, shared across boxes) — no separate launch step. Manage it with
`devbox proxy [start|stop|status]`.
`devbox proxy [start|stop|status|refresh]`. `refresh` updates every registered,
running box directly and never reads a project's `.devbox.toml`.

Every authenticated proxy request is also written to a host-owned, owner-only
audit log. It captures AI prompts/queries and GitHub API request payloads (with
Expand Down Expand Up @@ -286,8 +287,17 @@ For `gh`, log in once on the host with `gh auth login`; `devbox --proxy` gives
the guest CLI a dummy routing marker plus a short-lived Devbox proxy capability,
then injects the host token only inside a GitHub-only TLS proxy. The capability
is not a GitHub token, expires after eight hours, and is renewed every seven
hours while the `devbox --proxy` session is active. The bare proxy endpoint is
remembered on the host so re-entering a kept box renews its capability too.
hours by the long-lived host proxy daemon, independently of any `devbox` shell
or project manifest. It checks recorded box names once a minute, so a host
suspend or long idle is repaired promptly after resume without restarting the
guest. `devbox proxy refresh` forces the same update immediately.

To prevent an agent from accidentally bypassing the wrapper with Homebrew's
absolute path, `--proxy` copies the real `gh` binary into the managed private
wrapper directory and replaces Homebrew's public `bin/gh` link with the wrapper;
`--no-auth` restores the normal Homebrew link. This is command-routing hygiene,
not containment against hostile same-user guest code, which can still locate
and execute files it is permitted to access.
GitHub Enterprise hosts are not proxied. Git/GitHub SSH auth is separate: use **`--ssh-agent`**. It also enables automatic
SSH-format Git commit signatures through the forwarded agent. Devbox copies the
first public key exposed by `ssh-add -L` and the host Git name/email, then sets
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.0
1.2.1
135 changes: 98 additions & 37 deletions bin/devbox
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
# [--cpus N|-j N] [--memory S|-M S] [--disk S|-D S]
# devbox ls
# devbox destroy NAME | --all | --goldens
# devbox proxy [start|stop|status|audit] manage the shared host-side proxy
# devbox proxy [start|stop|status|refresh|audit]
# manage the shared host-side proxy
# devbox sessions [path|clear [--yes]] [DIR]
# inspect or remove persistent AI session state
# devbox --version|-V
Expand Down Expand Up @@ -141,9 +142,6 @@ GLOBAL_CONFIG="$CONFIG_DIR/config.toml"
DEFAULT_KEYS_FILE="$CONFIG_DIR/api-keys.env"
AGENT_SESSION_BASE="${DEVBOX_SESSION_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/devbox/sessions}"
PROXY_DEFAULT_URL="${DEVBOX_PROXY_URL:-http://host.lima.internal:4141}"
# A GitHub proxy capability is valid for eight hours. Refresh it well before
# expiry while this host-side `devbox` session remains attached to the guest.
GH_PROXY_CAPABILITY_RENEW_SECONDS="${DEVBOX_GH_PROXY_CAPABILITY_RENEW_SECONDS:-25200}"
PROJECT_MANIFEST_NAME=".devbox.toml"

# Host credential paths copied into the box under --with-creds (best-effort;
Expand Down Expand Up @@ -850,30 +848,9 @@ renew_gh_proxy_capability() { # $1 Lima instance $2 bare http proxy URL
}
}

start_gh_proxy_capability_renewal() { # $1 Lima instance $2 bare http proxy URL
local name="$1" url="$2"
[[ "$GH_PROXY_CAPABILITY_RENEW_SECONDS" =~ ^[1-9][0-9]*$ ]] || \
die "DEVBOX_GH_PROXY_CAPABILITY_RENEW_SECONDS must be a positive number of seconds"
(
while sleep "$GH_PROXY_CAPABILITY_RENEW_SECONDS"; do
[[ "$(instance_status "$name")" == "Running" ]] || continue
proxy_ensure "$url"
renew_gh_proxy_capability "$name" "$url" || \
warn "GitHub CLI proxy capability renewal failed; retrying in ${GH_PROXY_CAPABILITY_RENEW_SECONDS}s"
done
) &
_DB_GH_PROXY_RENEW_PID=$!
}

stop_gh_proxy_capability_renewal() {
local pid="${_DB_GH_PROXY_RENEW_PID:-}"
[[ -n "$pid" ]] || return 0
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
unset _DB_GH_PROXY_RENEW_PID
}
# is *our* proxy answering on this port? (health endpoint, not just any listener)
proxy_health() { curl -fsS --max-time 1 "http://127.0.0.1:$1/_devbox" 2>/dev/null | grep -q 'devbox-ai-proxy'; }
proxy_self_renewal_health() { curl -fsS --max-time 1 "http://127.0.0.1:$1/_devbox" 2>/dev/null | grep -q 'gh-self-renewal'; }
# is anything at all listening on the port?
proxy_port_open() { timeout 1 bash -c ">/dev/tcp/127.0.0.1/$1" 2>/dev/null; }
# locate a proxy asset across source (../proxy) and brew (../libexec/proxy)
Expand All @@ -899,11 +876,34 @@ proxy_launcher() {
command -v devbox-ai-proxy 2>/dev/null && return 0
return 1
}

proxy_stop_process() {
local pid
pid="$(cat "$CONFIG_DIR/proxy.pid" 2>/dev/null || true)"
if [[ -n "$pid" ]] && kill "$pid" 2>/dev/null; then
rm -f "$CONFIG_DIR/proxy.pid"
return 0
fi
if pkill -f 'devbox-ai-proxy\.py' 2>/dev/null; then
rm -f "$CONFIG_DIR/proxy.pid"
return 0
fi
return 1
}

# start the host proxy if it isn't already up (idempotent). Shared across boxes.
proxy_ensure() {
local url="$1" port launcher logf i
port="$(proxy_port "$url")"
if proxy_health "$port"; then log "credential proxy already running on 127.0.0.1:$port"; return 0; fi
if proxy_health "$port"; then
if proxy_self_renewal_health "$port"; then
log "credential proxy already running on 127.0.0.1:$port"
return 0
fi
log "Restarting host credential proxy once to enable persistent GitHub capability renewal"
proxy_stop_process || { warn "could not stop the older credential proxy"; return 1; }
for i in $(seq 1 20); do proxy_port_open "$port" || break; sleep 0.1; done
fi
if proxy_port_open "$port"; then
warn "port $port is held by another service (not devbox's proxy)."
warn " pick a free port: export DEVBOX_PROXY_URL=http://host.lima.internal:<PORT>"
Expand Down Expand Up @@ -970,6 +970,38 @@ PY
limactl copy "$ca_path" "$name:.devbox/gh-proxy/certs/devbox-gh-proxy-ca.pem"
# shellcheck disable=SC2016 # $HOME expands inside the guest shell.
limactl shell "$name" -- bash -c 'chmod 755 "$HOME/.devbox/gh-proxy/bin/gh"; chmod 644 "$HOME/.devbox/gh-proxy/certs/devbox-gh-proxy-ca.pem"'
# Keep the real Homebrew binary as a private wrapper dependency, then replace
# Homebrew's public `gh` link with the proxy wrapper. This covers agents that
# exec the usual absolute Homebrew path instead of asking a shell to resolve
# the managed gh() function. A same-user process can still deliberately find
# the private binary; this prevents accidental bypass, not hostile guest code.
# shellcheck disable=SC2016 # All variables expand in the guest.
limactl shell "$name" -- bash -s <<'GUEST'
set -euo pipefail
wrapper="$HOME/.devbox/gh-proxy/bin/gh"
real_dir="$HOME/.devbox/gh-proxy/libexec"
real_gh="$real_dir/gh-real"
local_shim="$HOME/.local/bin/gh"
brew_bin="$(command -v brew 2>/dev/null || true)"
[ -n "$brew_bin" ] || brew_bin=/home/linuxbrew/.linuxbrew/bin/brew
[ -x "$brew_bin" ] || { printf '%s\n' '[devbox] Homebrew is required for managed gh proxying' >&2; exit 1; }
formula_prefix="$("$brew_bin" --prefix gh 2>/dev/null || true)"
formula_gh="$formula_prefix/bin/gh"
[ -n "$formula_prefix" ] && [ -x "$formula_gh" ] \
|| { printf '%s\n' '[devbox] Homebrew gh is missing; rebuild the golden and recreate this box' >&2; exit 1; }
install -d -m 700 "$real_dir" "$HOME/.local/bin"
install -m 755 "$formula_gh" "$real_gh"

brew_prefix="$("$brew_bin" --prefix)"
brew_gh="$brew_prefix/bin/gh"
rm -f -- "$brew_gh"
ln -s "$wrapper" "$brew_gh"
if [ ! -e "$local_shim" ] && [ ! -L "$local_shim" ]; then
ln -s "$wrapper" "$local_shim"
elif [ "$(readlink -f "$local_shim" 2>/dev/null || true)" != "$wrapper" ]; then
printf '[devbox] WARN: preserving existing gh command at %s\n' "$local_shim" >&2
fi
GUEST
renew_gh_proxy_capability "$name" "$url" || die "could not issue GitHub CLI proxy capability"
limactl shell "$name" -- bash -c 'sudo tee /etc/profile.d/zz-devbox-12-gh-proxy.sh >/dev/null' <<EOF
# devbox --proxy: GitHub CLI uses a host-authenticated, GitHub-only TLS proxy.
Expand All @@ -996,9 +1028,34 @@ clear_auth() {
local name="$1"
log "Disabling devbox-managed auth in $name"
clear_gh_proxy_endpoint "$name"
# shellcheck disable=SC2016 # $HOME expands in the guest shell.
limactl shell "$name" -- bash -c \
'sudo rm -f /etc/profile.d/zz-devbox-10-proxy.sh /etc/profile.d/zz-devbox-11-codex-proxy.sh /etc/profile.d/zz-devbox-12-gh-proxy.sh /etc/profile.d/zz-devbox-20-keys.sh; rm -rf "$HOME/.devbox/codex-proxy" "$HOME/.devbox/gh-proxy"'
# Restore Homebrew's public gh link only when it still points at our wrapper.
# shellcheck disable=SC2016 # All variables expand in the guest.
limactl shell "$name" -- bash -s <<'GUEST'
set -euo pipefail
wrapper="$HOME/.devbox/gh-proxy/bin/gh"
local_shim="$HOME/.local/bin/gh"
restore_brew=0
brew_bin="$(command -v brew 2>/dev/null || true)"
[ -n "$brew_bin" ] || brew_bin=/home/linuxbrew/.linuxbrew/bin/brew
if [ -x "$brew_bin" ]; then
brew_gh="$("$brew_bin" --prefix)/bin/gh"
if [ -L "$brew_gh" ] && [ "$(readlink -f "$brew_gh" 2>/dev/null || true)" = "$wrapper" ]; then
rm -f -- "$brew_gh"
restore_brew=1
fi
fi
if [ -L "$local_shim" ] && [ "$(readlink -f "$local_shim" 2>/dev/null || true)" = "$wrapper" ]; then
rm -f -- "$local_shim"
fi
sudo rm -f /etc/profile.d/zz-devbox-10-proxy.sh \
/etc/profile.d/zz-devbox-11-codex-proxy.sh \
/etc/profile.d/zz-devbox-12-gh-proxy.sh \
/etc/profile.d/zz-devbox-20-keys.sh
rm -rf -- "$HOME/.devbox/codex-proxy" "$HOME/.devbox/gh-proxy"
if [ "$restore_brew" -eq 1 ]; then
"$brew_bin" link --overwrite gh >/dev/null
fi
GUEST
}

# ------------------------------------------------------- traffic audit egress ---
Expand Down Expand Up @@ -1723,7 +1780,6 @@ cmd_build() {
run_cleanup() {
local rc=$?
trap - EXIT INT TERM
stop_gh_proxy_capability_renewal
if [[ "${_DB_KEEP:-0}" -eq 1 ]]; then
log "--keep: retained ${_DB_NAME:-?}"
log " re-enter: devbox $(printf '%q' "${_DB_DIR:-.}")$([[ "${_DB_IMAGE:-}" != "$DEFAULT_IMAGE" ]] && printf ' --image %q' "${_DB_IMAGE:-}")"
Expand Down Expand Up @@ -1954,7 +2010,6 @@ cmd_run() {
[[ -z "$managed_proxy" ]] || {
proxy_ensure "$managed_proxy"
apply_proxy "$name" "$managed_proxy"
start_gh_proxy_capability_renewal "$name" "$managed_proxy"
}
[[ -n "$api_keys" ]] && apply_api_keys "$name" "$api_keys"
[[ $with_creds -eq 1 ]] && apply_creds "$name"
Expand Down Expand Up @@ -2023,11 +2078,17 @@ cmd_proxy() {
case "$sub" in
start) proxy_ensure "$url";;
stop) pid="$(cat "$CONFIG_DIR/proxy.pid" 2>/dev/null || true)"
if [[ -n "$pid" ]] && kill "$pid" 2>/dev/null; then
log "stopped AI proxy (pid $pid)"; rm -f "$CONFIG_DIR/proxy.pid"
elif pkill -f 'devbox-ai-proxy\.py' 2>/dev/null; then log "stopped AI proxy (matched by name)"
if proxy_stop_process; then log "stopped AI proxy${pid:+ (pid $pid)}"
else warn "no running AI proxy found"; fi;;
status) if proxy_health "$port"; then log "AI proxy: RUNNING on 127.0.0.1:$port"
refresh) proxy_ensure "$url"
launcher="$(proxy_launcher)" || die "proxy launcher not found"
"$launcher" --refresh-gh-proxy-boxes;;
status) if proxy_health "$port"; then
if proxy_self_renewal_health "$port"; then
log "AI proxy: RUNNING on 127.0.0.1:$port (GitHub capability self-renewal enabled)"
else
warn "AI proxy: RUNNING on 127.0.0.1:$port (restart required for GitHub capability self-renewal)"
fi
elif proxy_port_open "$port"; then warn "port $port held by another (non-devbox) service"
else log "AI proxy: not running (port $port)"; fi;;
audit) shift
Expand All @@ -2039,7 +2100,7 @@ cmd_proxy() {
export) audit_arg="${2:-}"; "$launcher" --audit-export "$audit_arg";;
*) die "usage: devbox proxy audit [status|show [LIMIT]|export [FILE]]";;
esac;;
*) die "usage: devbox proxy [start|stop|status|audit]";;
*) die "usage: devbox proxy [start|stop|status|refresh|audit]";;
esac
}

Expand Down
8 changes: 8 additions & 0 deletions docs/agent-capabilities-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ guest does not receive an access or refresh token. The GitHub wrapper also
rejects guest-side token-changing `gh auth` commands. See the [proxy
design](../proxy/README.md).

For `gh`, the host proxy daemon renews short-lived capabilities directly for
host-registered running Lima boxes; it does not re-evaluate project manifests or
restart guests. `--proxy` replaces Homebrew's public `gh` link with the wrapper
to prevent accidental direct execution, while retaining a private executable
copy for the wrapper itself. Because both remain executable by the guest user,
this routing measure does not stop deliberately hostile same-user code from
finding and invoking the private binary.

The host records detailed authenticated-proxy request audits by default,
including prompts and GitHub mutation payloads. This helps attribute actions,
but creates a second sensitive host-local data store. Read [proxy audit
Expand Down
40 changes: 24 additions & 16 deletions proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,21 @@ into the guest, and injects the host token after TLS termination for
`api.github.com` and `uploads.github.com`. The guest holds only the literal
`devbox-proxy` routing marker plus a short-lived Devbox proxy capability, never
the real GitHub token. The capability authenticates only the local proxy and
expires after eight hours. While the host-side `devbox --proxy` session remains
open, Devbox renews that capability every seven hours and atomically updates the
guest wrapper state. Devbox remembers only the bare proxy endpoint on the host,
so re-entering a kept box renews its capability even when `--proxy` is omitted.
expires after eight hours. The long-lived proxy daemon scans host-owned box
registrations once a minute and renews due capabilities every seven hours.
Renewal uses the recorded Lima box name directly: it does not depend on a
`devbox` terminal remaining open, re-read `.devbox.toml`, start a stopped box,
or restart a running guest. The wrapper reads the atomically replaced
capability file for every invocation.
GitHub-owned download hosts are tunnelled without TLS interception.

`--proxy` also moves the usable Homebrew `gh` binary into the wrapper's managed
private directory and replaces Homebrew's public `bin/gh` link with the wrapper.
That prevents ordinary child-process and absolute-Homebrew-path mistakes. The
real binary must remain executable by the same guest user for the wrapper to run
it, so this is not a security boundary against deliberately hostile guest code.
`--no-auth` restores Homebrew's normal link.

Log in on the host first:

```sh
Expand All @@ -149,22 +158,20 @@ for GitHub.com; GitHub Enterprise hosts remain direct guest configuration.

### Repairing an existing box

For a kept box where `gh` is installed but reports that it needs `gh auth
login`, refresh the Devbox-managed proxy setup without deleting the box:
For an immediate refresh of every registered running box, without resolving a
project manifest or opening/restarting a guest, run:

```sh
cd /path/to/project
devbox --keep --proxy
devbox proxy refresh
```

The command refreshes the guest wrapper and proxy profile, then opens the box.
Use `gh api /rate_limit --jq .rate.remaining` there as a credential-safe smoke
check. Do not run `gh auth login` in the guest; log in on the host instead.
For a session that was closed or suspended past the capability lifetime, the
same command issues a fresh capability before opening the guest. Subsequent
bare re-entry to that kept box does the same, using the host-owned remembered
endpoint; use `--no-auth` to remove the proxy configuration and remembered
endpoint.
Use `gh api /rate_limit --jq .rate.remaining` in the existing guest as a
credential-safe smoke check. Do not run `gh auth login` in the guest; log in on
the host instead. Re-entering a kept box still repairs the wrapper/profile and
records it for daemon renewal, but is no longer needed for routine refreshes.
After upgrading from a proxy version without daemon renewal, the first new
`devbox --proxy`, `devbox proxy start`, or `devbox proxy refresh` restarts only
the host proxy process once; it does not restart any guest.

If the box says `gh` is missing, it predates the golden-image installation.
First check that its project work is committed or otherwise safe, then rebuild
Expand Down Expand Up @@ -199,6 +206,7 @@ Manage the shared proxy directly if you want:
```sh
devbox proxy status # RUNNING / not running / port held by another service
devbox proxy start # start it without a box
devbox proxy refresh # renew every registered running box; no guest restart
devbox proxy stop # stop it
```

Expand Down
Loading