diff --git a/Dockerfile.base b/Dockerfile.base index b24a1ff3197..fdd34790c7b 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -240,11 +240,17 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ # /home/linuxbrew/.linuxbrew/bin/* is then dead code. # # Image-build runs as root, so we create the prefix, chown it to the -# sandbox user, clone Homebrew core under it as the sandbox user, and -# symlink the brew entry point into the Homebrew prefix and /usr/local/bin. -# /usr/local/bin is already on the locked sandbox PATH, so users can run -# `brew` without adding the sandbox-writable Homebrew prefix to privileged -# startup PATH. +# sandbox user, clone Homebrew core under it as the sandbox user, and expose a +# /usr/local/bin wrapper. /usr/local/bin is already on the locked sandbox PATH, +# but a plain symlink there makes Homebrew infer /usr/local as its prefix. The +# wrapper must execute the Linuxbrew prefix shim, not the repository script +# directly, so Homebrew keeps /home/linuxbrew/.linuxbrew as its writable prefix. +# The wrapper also pins Homebrew's temp extraction to /tmp, because the sandbox +# policy permits /tmp writes while /var/tmp stays outside the write set. +# Installed formulae are added to the sandbox user's login-shell PATH via +# /etc/profile.d instead of Docker ENV, because /home/linuxbrew is +# sandbox-writable and must not be inherited by privileged startup code before +# nemoclaw-start locks PATH down. # # Companion change: /home/linuxbrew is added to filesystem_policy.read_write # in nemoclaw-blueprint/policies/openclaw-sandbox.yaml so brew can write @@ -264,6 +270,32 @@ RUN mkdir -p /home/linuxbrew/.linuxbrew/bin \ /home/linuxbrew/.linuxbrew/Homebrew \ && ln -s /home/linuxbrew/.linuxbrew/Homebrew/bin/brew \ /home/linuxbrew/.linuxbrew/bin/brew \ - && ln -s /home/linuxbrew/.linuxbrew/Homebrew/bin/brew \ - /usr/local/bin/brew \ - && gosu sandbox brew --version + && { \ + printf '%s\n' '#!/bin/sh'; \ + printf '%s\n' 'export HOMEBREW_TEMP=/tmp'; \ + printf '%s\n' 'export TMPDIR=/tmp'; \ + printf '%s\n' 'exec /home/linuxbrew/.linuxbrew/bin/brew "$@"'; \ + } > /usr/local/bin/brew \ + && chmod 755 /usr/local/bin/brew \ + && grep -qx 'export HOMEBREW_TEMP=/tmp' /usr/local/bin/brew \ + && grep -qx 'export TMPDIR=/tmp' /usr/local/bin/brew \ + && gosu sandbox env HOMEBREW_TEMP=/var/tmp TMPDIR=/var/tmp /usr/local/bin/brew --prefix \ + | grep -qx /home/linuxbrew/.linuxbrew \ + && gosu sandbox /usr/local/bin/brew --prefix | grep -qx /home/linuxbrew/.linuxbrew \ + && gosu sandbox /usr/local/bin/brew --version +RUN { \ + printf '%s\n' "if [ \"\$(/usr/bin/id -un 2>/dev/null || true)\" = sandbox ]; then"; \ + printf '%s\n' " export PATH=\"\${PATH}:/home/linuxbrew/.linuxbrew/bin\""; \ + printf '%s\n' "fi"; \ + } > /etc/profile.d/nemoclaw-linuxbrew.sh \ + && chmod 644 /etc/profile.d/nemoclaw-linuxbrew.sh \ + && bash -lc "case \":\${PATH}:\" in *:/home/linuxbrew/.linuxbrew/bin:*) exit 1 ;; *) exit 0 ;; esac" \ + && mkdir -p /tmp/nemoclaw-hostile-bin \ + && { printf '%s\n' '#!/bin/sh'; printf '%s\n' 'echo sandbox'; } > /tmp/nemoclaw-hostile-bin/id \ + && chmod 755 /tmp/nemoclaw-hostile-bin/id \ + && PATH="/tmp/nemoclaw-hostile-bin:${PATH}" bash -lc "case \":\${PATH}:\" in *:/home/linuxbrew/.linuxbrew/bin:*) exit 1 ;; *) exit 0 ;; esac" \ + && rm -rf /tmp/nemoclaw-hostile-bin \ + && gosu sandbox bash -lc 'command -v brew >/dev/null' \ + && gosu sandbox bash -lc 'command -v brew' | grep -qx /usr/local/bin/brew \ + && gosu sandbox bash -lc 'brew --prefix' | grep -qx /home/linuxbrew/.linuxbrew \ + && gosu sandbox bash -lc "case \":\${PATH}:\" in *:/home/linuxbrew/.linuxbrew/bin:*) exit 0 ;; *) exit 1 ;; esac" diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index d4de69f8616..601cfcf4e41 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -276,11 +276,13 @@ $ nemoclaw my-assistant policy-remove huggingface --yes ### Homebrew Specifics The sandbox base image includes Homebrew (Linuxbrew), so applying the `brew` preset is the only step needed before installing a formula. -A `/usr/local/bin/brew` symlink puts the entry point on the sandbox `PATH`, so the agent can run `brew install ` directly: +A `/usr/local/bin/brew` wrapper puts the entry point on the sandbox `PATH` while delegating to the Linuxbrew prefix. +Installed formula commands are available from the Linuxbrew bin directory in sandbox shell sessions: ```console $ nemoclaw my-assistant policy-add brew --yes $ nemoclaw my-assistant exec -- brew install +$ nemoclaw my-assistant exec -- bash -lc '' ``` You do not need to bootstrap Homebrew, install build dependencies, or source `brew shellenv` inside the sandbox. diff --git a/test/e2e/test-network-policy.sh b/test/e2e/test-network-policy.sh index 2d204b29b4e..3adf2dcbf53 100755 --- a/test/e2e/test-network-policy.sh +++ b/test/e2e/test-network-policy.sh @@ -18,6 +18,7 @@ # TC-NET-09: SSRF validation (dangerous IPs rejected) # TC-NET-10: OpenClaw web_fetch can reach approved host gateway target, # while OpenShell still denies unapproved host gateway ports +# TC-NET-11: Homebrew preset installs and runs a formula end-to-end # # Prerequisites: # - Docker running @@ -37,6 +38,8 @@ source "${SCRIPT_DIR_TIMEOUT}/lib/install-path-refresh.sh" # ── Config ─────────────────────────────────────────────────────────────────── SANDBOX_NAME="e2e-net-policy" LOG_FILE="test-network-policy-$(date +%Y%m%d-%H%M%S).log" +SANDBOX_EXEC_TIMEOUT_SECONDS=120 +PACKAGE_MANAGER_SANDBOX_TIMEOUT_SECONDS=300 # ── Colors ─────────────────────────────────────────────────────────────────── GREEN='\033[0;32m' @@ -174,6 +177,7 @@ EOF # Execute a command inside the sandbox via SSH. sandbox_exec() { local cmd="$1" + local timeout_seconds="${2:-$SANDBOX_EXEC_TIMEOUT_SECONDS}" local ssh_cfg ssh_cfg="$(mktemp)" if ! openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_cfg" 2>/dev/null; then @@ -183,7 +187,7 @@ sandbox_exec() { return 1 fi local result ssh_exit=0 - result=$(run_with_timeout 120 ssh -F "$ssh_cfg" \ + result=$(run_with_timeout "$timeout_seconds" ssh -F "$ssh_cfg" \ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=10 -o LogLevel=ERROR \ "openshell-${SANDBOX_NAME}" "$cmd" 2>&1) || ssh_exit=$? @@ -321,6 +325,101 @@ test_net_02_whitelist_access() { fi } +# ============================================================================= +# TC-NET-11: Homebrew preset install/use path +# ============================================================================= +test_net_11_brew_install_hello() { + log "=== TC-NET-11: Homebrew Preset Installs and Runs hello ===" + + log " Adding brew preset for Homebrew formula install test..." + if ! apply_preset "brew"; then + fail "TC-NET-11: Setup" "Could not apply brew preset" + return + fi + + local policy_list + if ! policy_list=$(nemoclaw "$SANDBOX_NAME" policy-list 2>&1); then + fail "TC-NET-11: policy-list" "policy-list failed after brew preset: ${policy_list:0:500}" + return + fi + log " policy-list: ${policy_list:0:600}" + if printf '%s\n' "$policy_list" | grep -E "^[[:space:]]*●[[:space:]]+brew[[:space:]]" >/dev/null; then + pass "TC-NET-11: policy-list shows brew applied" + else + fail "TC-NET-11: policy-list" "brew preset not marked applied: ${policy_list:0:500}" + return + fi + + local connect_probe connect_rc=0 + connect_probe=$(run_with_timeout 60 nemoclaw "$SANDBOX_NAME" connect --probe-only 2>&1) || connect_rc=$? + log " connect --probe-only: ${connect_probe:0:500}" + if [[ $connect_rc -eq 0 ]]; then + pass "TC-NET-11: nemoclaw connect --probe-only reaches sandbox" + else + fail "TC-NET-11: connect --probe-only" "connect probe failed: ${connect_probe:0:500}" + return + fi + + log " Probing Homebrew policy endpoints and installing hello through the wrapper..." + local brew_probe_script brew_probe_b64 response + brew_probe_script="$( + cat <<'BREW_PROBE' +set -euo pipefail +export HOMEBREW_NO_AUTO_UPDATE=1 +export HOMEBREW_NO_ENV_HINTS=1 + +check_status() { + local name="$1" + local url="$2" + local status + status=$(curl -sS -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "$url") || { + echo "BREW_ENDPOINT_${name}_CURL_FAILED" + return 1 + } + case "$status" in + 2??|3??|401) + echo "BREW_ENDPOINT_${name}_OK_${status}" + ;; + *) + echo "BREW_ENDPOINT_${name}_BAD_${status}" + return 1 + ;; + esac +} + +check_status formulae https://formulae.brew.sh +check_status raw https://raw.githubusercontent.com/Homebrew/brew/HEAD/README.md +git ls-remote https://github.com/Homebrew/brew.git HEAD >/dev/null +echo "BREW_ENDPOINT_github_OK" +check_status ghcr https://ghcr.io/v2/ + +command -v brew +brew --prefix +brew install --quiet hello +command -v hello +hello +BREW_PROBE + )" + brew_probe_b64="$(printf '%s' "$brew_probe_script" | base64 | tr -d '\n')" + response=$(sandbox_exec "printf '%s' '${brew_probe_b64}' | base64 -d > /tmp/nemoclaw-brew-e2e.sh +bash /tmp/nemoclaw-brew-e2e.sh" "$PACKAGE_MANAGER_SANDBOX_TIMEOUT_SECONDS" 2>&1) || true + + log " Response: ${response:0:1000}" + + if echo "$response" | grep -q "BREW_ENDPOINT_formulae_OK_" \ + && echo "$response" | grep -q "BREW_ENDPOINT_raw_OK_" \ + && echo "$response" | grep -q "BREW_ENDPOINT_github_OK" \ + && echo "$response" | grep -q "BREW_ENDPOINT_ghcr_OK_" \ + && echo "$response" | grep -q "/usr/local/bin/brew" \ + && echo "$response" | grep -q "/home/linuxbrew/.linuxbrew" \ + && echo "$response" | grep -q "/home/linuxbrew/.linuxbrew/bin/hello" \ + && echo "$response" | grep -q "Hello, world!"; then + pass "TC-NET-11: brew preset installed hello and ran the formula command" + else + fail "TC-NET-11: Homebrew install" "brew install/use path failed: ${response:0:500}" + fi +} + # ============================================================================= # TC-NET-03: Live policy-add without restart # ============================================================================= @@ -974,6 +1073,7 @@ main() { setup_sandbox test_net_01_deny_default + test_net_11_brew_install_hello test_net_02_whitelist_access test_net_03_live_policy_add test_net_04_dry_run diff --git a/test/policies.test.ts b/test/policies.test.ts index cd4889493a4..efafe5b48b4 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1641,7 +1641,7 @@ exit 1 } }); - it("brew preset whitelists the PATH shim and Homebrew-managed entrypoints (#3913)", () => { + it("brew preset whitelists the PATH wrapper and Homebrew-managed entrypoints (#3913)", () => { const content = requirePresetContent(policies.loadPreset("brew")); const parsed = YAML.parse(content); const brewPolicy = parsed.network_policies?.brew as