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
48 changes: 40 additions & 8 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
4 changes: 3 additions & 1 deletion docs/network-policy/integration-policy-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <formula>` 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 <formula>
$ nemoclaw my-assistant exec -- bash -lc '<formula-command>'
```

You do not need to bootstrap Homebrew, install build dependencies, or source `brew shellenv` inside the sandbox.
Expand Down
102 changes: 101 additions & 1 deletion test/e2e/test-network-policy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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=$?
Expand Down Expand Up @@ -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
# =============================================================================
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading