VYPR
Medium severity6.5NVD Advisory· Published Apr 10, 2026· Updated Apr 13, 2026

CVE-2026-35658

CVE-2026-35658

Description

OpenClaw before 2026.3.2 contains a filesystem boundary bypass vulnerability in the image tool that fails to honor tools.fs.workspaceOnly restrictions. Attackers can traverse sandbox bridge mounts outside the workspace to read files that other filesystem tools would reject.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.22026.3.2

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.2

Patches

4
630f1479c44f

build: prepare 2026.3.23-2

https://github.com/openclaw/openclawPeter SteinbergerMar 24, 2026via ghsa
3 files changed · +26 24
  • CHANGELOG.md+24 22 modified
    @@ -8,45 +8,47 @@ Docs: https://docs.openclaw.ai
     
     ### Changes
     
    -- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    -- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    -- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    -
     ### Fixes
     
    -- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    -- CLI/channel auth: auto-select the single login-capable configured channel for `channels login`/`logout` instead of relying on the outbound message-channel resolver, so env-only or non-auth channels no longer cause false ambiguity errors. (#53254) Thanks @BunsDev.
    -- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    -- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
    -- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    -- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    -- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    -- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    -- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    -- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    -- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    -- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
    -- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    -- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    -- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    -
     ## 2026.3.23
     
     ### Breaking
     
     ### Changes
     
    +- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    +- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    +- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    +
     ### Fixes
     
    +- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces.
    +- CLI/channel auth: auto-select the single configured login-capable channel for `channels login`/`logout`, harden channel ids against prototype-chain and control-character abuse, and fall back cleanly to catalog-backed channel installs, so channel auth works again for single-channel setups and on-demand channel installs. (#53254) Thanks @BunsDev.
    +- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    +- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    +- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    +- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
     - Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
     - Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
    +- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
    +- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
     - ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
     - ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
     - ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
    +- Config/warnings: suppress the confusing “newer OpenClaw” warning when a config written by a same-base correction release like `2026.3.23-2` is read by `2026.3.23`, while still warning for truly newer or incompatible versions.
    +- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    +- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    +- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    +- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    +- Telegram/threading: populate `currentThreadTs` in the threading tool-context fallback for Telegram DM topics so thread-aware tools still receive the active topic context when the main thread metadata is missing. (#52217)
    +- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    +- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    +- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    +- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    +- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    +- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
     - Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
     - Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
    -- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
    -- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
     - Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
     - Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
     - Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
    
  • docs/.generated/plugin-sdk-api-baseline.json+1 1 modified
    @@ -2620,7 +2620,7 @@
               "exportName": "resolveCommandAuthorization",
               "kind": "function",
               "source": {
    -            "line": 303,
    +            "line": 440,
                 "path": "src/auto-reply/command-auth.ts"
               }
             },
    
  • docs/.generated/plugin-sdk-api-baseline.jsonl+1 1 modified
    @@ -287,7 +287,7 @@
     {"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"}
    -{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":303,"sourcePath":"src/auto-reply/command-auth.ts"}
    +{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"}
     {"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
    
ccfeecb6887c

test: harden parallels macos dashboard smoke

https://github.com/openclaw/openclawPeter SteinbergerMar 23, 2026via ghsa
2 files changed · +107 3
  • .agents/skills/openclaw-parallels-smoke/SKILL.md+1 0 modified
    @@ -32,6 +32,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
     - Preferred entrypoint: `pnpm test:parallels:macos`
     - Default to the snapshot closest to `macOS 26.3.1 latest`.
     - On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
    +- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
     - `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
     - Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
     - On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
    
  • scripts/e2e/parallels-macos-smoke.sh+106 3 modified
    @@ -31,6 +31,7 @@ GUEST_NPM_BIN="/opt/homebrew/bin/npm"
     
     MAIN_TGZ_DIR="$(mktemp -d)"
     MAIN_TGZ_PATH=""
    +PACKED_MAIN_COMMIT_SHORT=""
     SERVER_PID=""
     RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)"
     BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
    @@ -41,6 +42,7 @@ TIMEOUT_ONBOARD_S=180
     TIMEOUT_GATEWAY_S=60
     TIMEOUT_AGENT_S=120
     TIMEOUT_PERMISSION_S=60
    +TIMEOUT_DASHBOARD_S=60
     TIMEOUT_SNAPSHOT_S=180
     TIMEOUT_DISCORD_S=180
     
    @@ -51,6 +53,8 @@ FRESH_GATEWAY_STATUS="skip"
     UPGRADE_GATEWAY_STATUS="skip"
     FRESH_AGENT_STATUS="skip"
     UPGRADE_AGENT_STATUS="skip"
    +FRESH_DASHBOARD_STATUS="skip"
    +UPGRADE_DASHBOARD_STATUS="skip"
     FRESH_DISCORD_STATUS="skip"
     UPGRADE_DISCORD_STATUS="skip"
     
    @@ -562,8 +566,12 @@ extract_package_version_from_tgz() {
       tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
     }
     
    +extract_package_build_commit_from_tgz() {
    +  tar -xOf "$1" package/dist/build-info.json | python3 -c 'import json, sys; print(json.load(sys.stdin).get("commit", ""))'
    +}
    +
     pack_main_tgz() {
    -  local short_head pkg
    +  local short_head pkg packed_commit
       if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
         say "Pack target package tgz: $TARGET_PACKAGE_SPEC"
         pkg="$(
    @@ -578,13 +586,17 @@ pack_main_tgz() {
       fi
       say "Pack current main tgz"
       ensure_current_build
    +  stage_pack_runtime_deps
       short_head="$(git rev-parse --short HEAD)"
       pkg="$(
         npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
           | python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])'
       )"
       MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz"
       cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH"
    +  packed_commit="$(extract_package_build_commit_from_tgz "$MAIN_TGZ_PATH")"
    +  [[ -n "$packed_commit" ]] || die "failed to read packed build commit from $MAIN_TGZ_PATH"
    +  PACKED_MAIN_COMMIT_SHORT="${packed_commit:0:7}"
       say "Packed $MAIN_TGZ_PATH"
       tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json
     }
    @@ -594,7 +606,8 @@ verify_target_version() {
         verify_version_contains "$TARGET_EXPECT_VERSION"
         return
       fi
    -  verify_version_contains "$(git rev-parse --short=7 HEAD)"
    +  [[ -n "$PACKED_MAIN_COMMIT_SHORT" ]] || die "packed main commit not captured"
    +  verify_version_contains "$PACKED_MAIN_COMMIT_SHORT"
     }
     
     current_build_commit() {
    @@ -610,6 +623,10 @@ else:
     PY
     }
     
    +current_control_ui_ready() {
    +  [[ -f "dist/control-ui/index.html" ]]
    +}
    +
     acquire_build_lock() {
       local owner_pid=""
       while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do
    @@ -637,15 +654,22 @@ ensure_current_build() {
       acquire_build_lock
       head="$(git rev-parse HEAD)"
       build_commit="$(current_build_commit)"
    -  if [[ "$build_commit" == "$head" ]]; then
    +  if [[ "$build_commit" == "$head" ]] && current_control_ui_ready; then
         release_build_lock
         return
       fi
       say "Build dist for current head"
       pnpm build
    +  say "Build Control UI for current head"
    +  pnpm ui:build
       build_commit="$(current_build_commit)"
       release_build_lock
       [[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build"
    +  current_control_ui_ready || die "dist/control-ui/index.html missing after ui build"
    +}
    +
    +stage_pack_runtime_deps() {
    +  node scripts/stage-bundled-plugin-runtime-deps.mjs
     }
     
     start_server() {
    @@ -719,6 +743,77 @@ verify_turn() {
         --json
     }
     
    +resolve_dashboard_url() {
    +  local dashboard_url
    +  dashboard_url="$(
    +    guest_current_user_cli "$GUEST_OPENCLAW_BIN" dashboard --no-open \
    +      | awk '/^Dashboard URL: / { sub(/^Dashboard URL: /, ""); print; exit }'
    +  )"
    +  dashboard_url="${dashboard_url//$'\r'/}"
    +  dashboard_url="${dashboard_url//$'\n'/}"
    +  [[ -n "$dashboard_url" ]] || {
    +    echo "failed to resolve dashboard URL from openclaw dashboard --no-open" >&2
    +    return 1
    +  }
    +  printf '%s\n' "$dashboard_url"
    +}
    +
    +verify_dashboard_load() {
    +  local dashboard_url dashboard_http_url dashboard_url_q dashboard_http_url_q cmd
    +  dashboard_url="$(resolve_dashboard_url)"
    +  dashboard_http_url="${dashboard_url%%#*}"
    +  dashboard_url_q="$(shell_quote "$dashboard_url")"
    +  dashboard_http_url_q="$(shell_quote "$dashboard_http_url")"
    +  cmd="$(cat <<EOF
    +set -eu
    +export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin:\${PATH:-}"
    +if [ -z "\${HOME:-}" ]; then export HOME="/Users/\$(id -un)"; fi
    +cd "\$HOME"
    +dashboard_url=$dashboard_url_q
    +dashboard_http_url=$dashboard_http_url_q
    +dashboard_port=\$(printf '%s\n' "\$dashboard_http_url" | sed -E 's#^https?://[^:/]+:([0-9]+).*\$#\1#')
    +if [ -z "\$dashboard_port" ] || [ "\$dashboard_port" = "\$dashboard_http_url" ]; then
    +  echo "failed to parse dashboard port from \$dashboard_http_url" >&2
    +  exit 1
    +fi
    +deadline=\$((SECONDS + 30))
    +dashboard_ready=0
    +while [ \$SECONDS -lt \$deadline ]; do
    +  if curl -fsSL "\$dashboard_http_url" >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then
    +    if grep -F '<title>OpenClaw Control</title>' /tmp/openclaw-dashboard-smoke.html >/dev/null; then
    +      if grep -F '<openclaw-app></openclaw-app>' /tmp/openclaw-dashboard-smoke.html >/dev/null; then
    +        dashboard_ready=1
    +        break
    +      fi
    +    fi
    +  fi
    +  sleep 1
    +done
    +[ "\$dashboard_ready" = "1" ] || {
    +  echo "dashboard HTML did not become ready at \$dashboard_http_url" >&2
    +  exit 1
    +}
    +grep -F '<title>OpenClaw Control</title>' /tmp/openclaw-dashboard-smoke.html >/dev/null
    +grep -F '<openclaw-app></openclaw-app>' /tmp/openclaw-dashboard-smoke.html >/dev/null
    +pkill -x Safari >/dev/null 2>&1 || true
    +open -a Safari "\$dashboard_url"
    +deadline=\$((SECONDS + 20))
    +while [ \$SECONDS -lt \$deadline ]; do
    +  if pgrep -x Safari >/dev/null 2>&1; then
    +    if lsof -nPiTCP:"\$dashboard_port" -sTCP:ESTABLISHED 2>/dev/null \
    +      | awk 'NR > 1 && \$1 != "node" { found = 1 } END { exit found ? 0 : 1 }'; then
    +      exit 0
    +    fi
    +  fi
    +  sleep 1
    +done
    +echo "Safari did not establish a dashboard client connection on port \$dashboard_port" >&2
    +exit 1
    +EOF
    +)"
    +  guest_current_user_exec /bin/sh -lc "$cmd"
    +}
    +
     configure_discord_smoke() {
       local guilds_json script
       guilds_json="$(
    @@ -996,6 +1091,7 @@ summary = {
             "version": os.environ["SUMMARY_FRESH_MAIN_VERSION"],
             "gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"],
             "agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"],
    +        "dashboard": os.environ["SUMMARY_FRESH_DASHBOARD_STATUS"],
             "discord": os.environ["SUMMARY_FRESH_DISCORD_STATUS"],
         },
         "upgrade": {
    @@ -1005,6 +1101,7 @@ summary = {
             "mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"],
             "gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"],
             "agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"],
    +        "dashboard": os.environ["SUMMARY_UPGRADE_DASHBOARD_STATUS"],
             "discord": os.environ["SUMMARY_UPGRADE_DISCORD_STATUS"],
         },
     }
    @@ -1041,6 +1138,8 @@ run_fresh_main_lane() {
       phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
       phase_run "fresh.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway
       FRESH_GATEWAY_STATUS="pass"
    +  phase_run "fresh.dashboard-load" "$TIMEOUT_DASHBOARD_S" verify_dashboard_load
    +  FRESH_DASHBOARD_STATUS="pass"
       phase_run "fresh.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn
       FRESH_AGENT_STATUS="pass"
       if discord_smoke_enabled; then
    @@ -1074,6 +1173,8 @@ run_upgrade_lane() {
       phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
       phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway
       UPGRADE_GATEWAY_STATUS="pass"
    +  phase_run "upgrade.dashboard-load" "$TIMEOUT_DASHBOARD_S" verify_dashboard_load
    +  UPGRADE_DASHBOARD_STATUS="pass"
       phase_run "upgrade.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn
       UPGRADE_AGENT_STATUS="pass"
       if discord_smoke_enabled; then
    @@ -1153,13 +1254,15 @@ SUMMARY_JSON_PATH="$(
       SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \
       SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \
       SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \
    +  SUMMARY_FRESH_DASHBOARD_STATUS="$FRESH_DASHBOARD_STATUS" \
       SUMMARY_FRESH_DISCORD_STATUS="$FRESH_DISCORD_STATUS" \
       SUMMARY_UPGRADE_PRECHECK_STATUS="$UPGRADE_PRECHECK_STATUS" \
       SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \
       SUMMARY_LATEST_INSTALLED_VERSION="$LATEST_INSTALLED_VERSION" \
       SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \
       SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \
       SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \
    +  SUMMARY_UPGRADE_DASHBOARD_STATUS="$UPGRADE_DASHBOARD_STATUS" \
       SUMMARY_UPGRADE_DISCORD_STATUS="$UPGRADE_DISCORD_STATUS" \
       write_summary_json
     )"
    
14baadda2c45

fix(tools): honor fsPolicy.workspaceOnly in image/pdf tool localRoots

https://github.com/openclaw/openclawjustinhuangcodeMar 2, 2026via ghsa
4 files changed · +73 2
  • src/agents/tools/image-tool.test.ts+37 0 modified
    @@ -461,6 +461,43 @@ describe("image tool implicit imageModel config", () => {
         });
       });
     
    +  it("respects fsPolicy.workspaceOnly for non-sandbox image paths", async () => {
    +    await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
    +      const fetch = stubMinimaxOkFetch();
    +      const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
    +      try {
    +        const cfg = createMinimaxImageConfig();
    +
    +        const tool = requireImageTool(
    +          createImageTool({
    +            config: cfg,
    +            agentDir,
    +            workspaceDir,
    +            fsPolicy: { workspaceOnly: true },
    +          }),
    +        );
    +
    +        // File inside workspace is allowed.
    +        await expectImageToolExecOk(tool, imagePath);
    +        expect(fetch).toHaveBeenCalledTimes(1);
    +
    +        // File outside workspace is rejected even without sandbox.
    +        const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-outside-"));
    +        const outsideImage = path.join(outsideDir, "secret.png");
    +        await fs.writeFile(outsideImage, Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
    +        try {
    +          await expect(
    +            tool.execute("t2", { prompt: "Describe.", image: outsideImage }),
    +          ).rejects.toThrow(/not under an allowed directory/i);
    +        } finally {
    +          await fs.rm(outsideDir, { recursive: true, force: true });
    +        }
    +      } finally {
    +        await fs.rm(agentDir, { recursive: true, force: true });
    +      }
    +    });
    +  });
    +
       it("allows workspace images via createOpenClawCodingTools default workspace root", async () => {
         await withTempWorkspacePng(async ({ imagePath }) => {
           const fetch = stubMinimaxOkFetch();
    
  • src/agents/tools/image-tool.ts+4 1 modified
    @@ -309,8 +309,11 @@ export function createImageTool(options?: {
         : "Analyze one or more images with the configured image model (agents.defaults.imageModel). Use image for a single path/URL, or images for multiple (up to 20). Provide a prompt describing what to analyze.";
     
       const localRoots = (() => {
    -    const roots = getDefaultLocalRoots();
         const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
    +    if (options?.fsPolicy?.workspaceOnly) {
    +      return workspaceDir ? [workspaceDir] : [];
    +    }
    +    const roots = getDefaultLocalRoots();
         if (!workspaceDir) {
           return roots;
         }
    
  • src/agents/tools/pdf-tool.test.ts+28 0 modified
    @@ -326,6 +326,34 @@ describe("createPdfTool", () => {
         });
       });
     
    +  it("respects fsPolicy.workspaceOnly for non-sandbox pdf paths", async () => {
    +    await withTempAgentDir(async (agentDir) => {
    +      vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
    +      const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-ws-"));
    +      const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-out-"));
    +      try {
    +        const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
    +        const tool = createPdfTool({
    +          config: cfg,
    +          agentDir,
    +          workspaceDir,
    +          fsPolicy: { workspaceOnly: true },
    +        });
    +        expect(tool).not.toBeNull();
    +
    +        const outsidePdf = path.join(outsideDir, "secret.pdf");
    +        await fs.writeFile(outsidePdf, "%PDF-1.4 fake");
    +
    +        await expect(tool!.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow(
    +          /not under an allowed directory/i,
    +        );
    +      } finally {
    +        await fs.rm(workspaceDir, { recursive: true, force: true });
    +        await fs.rm(outsideDir, { recursive: true, force: true });
    +      }
    +    });
    +  });
    +
       it("rejects unsupported scheme references", async () => {
         await withTempAgentDir(async (agentDir) => {
           vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
    
  • src/agents/tools/pdf-tool.ts+4 1 modified
    @@ -339,8 +339,11 @@ export function createPdfTool(options?: {
           : DEFAULT_MAX_PAGES;
     
       const localRoots = (() => {
    -    const roots = getDefaultLocalRoots();
         const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
    +    if (options?.fsPolicy?.workspaceOnly) {
    +      return workspaceDir ? [workspaceDir] : [];
    +    }
    +    const roots = getDefaultLocalRoots();
         if (!workspaceDir) {
           return roots;
         }
    
dd9d9c1c609d

fix(security): enforce workspaceOnly for sandbox image tool

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
5 files changed · +129 2
  • CHANGELOG.md+1 0 modified
    @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
     - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung.
     - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
     - Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
    +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
     
     ## 2026.2.23 (Unreleased)
     
    
  • src/agents/openclaw-tools.ts+2 0 modified
    @@ -41,6 +41,7 @@ export function createOpenClawTools(options?: {
       agentDir?: string;
       sandboxRoot?: string;
       sandboxFsBridge?: SandboxFsBridge;
    +  workspaceOnly?: boolean;
       workspaceDir?: string;
       sandboxed?: boolean;
       config?: OpenClawConfig;
    @@ -78,6 +79,7 @@ export function createOpenClawTools(options?: {
               options?.sandboxRoot && options?.sandboxFsBridge
                 ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
                 : undefined,
    +        workspaceOnly: options?.workspaceOnly,
             modelHasVision: options?.modelHasVision,
           })
         : null;
    
  • src/agents/pi-tools.ts+1 0 modified
    @@ -458,6 +458,7 @@ export function createOpenClawCodingTools(options?: {
           agentDir: options?.agentDir,
           sandboxRoot,
           sandboxFsBridge,
    +      workspaceOnly,
           workspaceDir: workspaceRoot,
           sandboxed: !!sandbox,
           config: options?.config,
    
  • src/agents/tools/image-tool.test.ts+103 1 modified
    @@ -6,7 +6,13 @@ import type { OpenClawConfig } from "../../config/config.js";
     import type { ModelDefinitionConfig } from "../../config/types.models.js";
     import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
     import { createOpenClawCodingTools } from "../pi-tools.js";
    -import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
    +import type { SandboxContext } from "../sandbox.js";
    +import type { SandboxFsBridge, SandboxResolvedPath } from "../sandbox/fs-bridge.js";
    +import {
    +  createHostSandboxFsBridge,
    +  createSandboxFsBridgeFromResolver,
    +} from "../test-helpers/host-sandbox-fs-bridge.js";
    +import { createPiToolsSandboxContext } from "../test-helpers/pi-tools-sandbox-context.js";
     import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
     
     async function writeAuthProfiles(agentDir: string, profiles: unknown) {
    @@ -46,6 +52,58 @@ async function withTempWorkspacePng(
       }
     }
     
    +function createUnsafeMountedBridge(params: {
    +  root: string;
    +  agentHostRoot: string;
    +  workspaceContainerRoot?: string;
    +}): SandboxFsBridge {
    +  const root = path.resolve(params.root);
    +  const agentHostRoot = path.resolve(params.agentHostRoot);
    +  const workspaceContainerRoot = params.workspaceContainerRoot ?? "/workspace";
    +
    +  const resolvePath = (filePath: string, cwd?: string): SandboxResolvedPath => {
    +    const hostPath =
    +      filePath === "/agent" || filePath === "/agent/" || filePath.startsWith("/agent/")
    +        ? path.join(
    +            agentHostRoot,
    +            filePath === "/agent" || filePath === "/agent/" ? "" : filePath.slice("/agent/".length),
    +          )
    +        : path.isAbsolute(filePath)
    +          ? filePath
    +          : path.resolve(cwd ?? root, filePath);
    +
    +    const relFromRoot = path.relative(root, hostPath);
    +    const relativePath =
    +      relFromRoot && !relFromRoot.startsWith("..") && !path.isAbsolute(relFromRoot)
    +        ? relFromRoot.split(path.sep).filter(Boolean).join(path.posix.sep)
    +        : filePath.replace(/\\/g, "/");
    +
    +    const containerPath = filePath.startsWith("/")
    +      ? filePath.replace(/\\/g, "/")
    +      : relativePath
    +        ? path.posix.join(workspaceContainerRoot, relativePath)
    +        : workspaceContainerRoot;
    +
    +    return { hostPath, relativePath, containerPath };
    +  };
    +
    +  return createSandboxFsBridgeFromResolver(resolvePath);
    +}
    +
    +function createSandbox(params: {
    +  sandboxRoot: string;
    +  agentRoot: string;
    +  fsBridge: SandboxFsBridge;
    +}): SandboxContext {
    +  return createPiToolsSandboxContext({
    +    workspaceDir: params.sandboxRoot,
    +    agentWorkspaceDir: params.agentRoot,
    +    workspaceAccess: "rw",
    +    fsBridge: params.fsBridge,
    +    tools: { allow: [], deny: [] },
    +  });
    +}
    +
     function stubMinimaxOkFetch() {
       const fetch = vi.fn().mockResolvedValue({
         ok: true,
    @@ -503,6 +561,50 @@ describe("image tool implicit imageModel config", () => {
         );
       });
     
    +  it("applies tools.fs.workspaceOnly to image paths in sandbox mode", async () => {
    +    const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
    +    const agentDir = path.join(stateDir, "agent");
    +    const sandboxRoot = path.join(stateDir, "sandbox");
    +    await fs.mkdir(agentDir, { recursive: true });
    +    await fs.mkdir(sandboxRoot, { recursive: true });
    +    await fs.writeFile(path.join(agentDir, "secret.png"), Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
    +
    +    const bridge = createUnsafeMountedBridge({ root: sandboxRoot, agentHostRoot: agentDir });
    +    const sandbox = createSandbox({ sandboxRoot, agentRoot: agentDir, fsBridge: bridge });
    +    const fetch = stubMinimaxOkFetch();
    +    const cfg: OpenClawConfig = {
    +      ...createMinimaxImageConfig(),
    +      tools: { fs: { workspaceOnly: true } },
    +    };
    +
    +    try {
    +      const tools = createOpenClawCodingTools({
    +        config: cfg,
    +        agentDir,
    +        sandbox,
    +        workspaceDir: sandboxRoot,
    +      });
    +      const readTool = tools.find((candidate) => candidate.name === "read");
    +      if (!readTool) {
    +        throw new Error("expected read tool");
    +      }
    +      const imageTool = requireImageTool(tools.find((candidate) => candidate.name === "image"));
    +
    +      await expect(readTool.execute("t1", { path: "/agent/secret.png" })).rejects.toThrow(
    +        /Path escapes sandbox root/i,
    +      );
    +      await expect(
    +        imageTool.execute("t2", {
    +          prompt: "Describe the image.",
    +          image: "/agent/secret.png",
    +        }),
    +      ).rejects.toThrow(/Path escapes sandbox root/i);
    +      expect(fetch).not.toHaveBeenCalled();
    +    } finally {
    +      await fs.rm(stateDir, { recursive: true, force: true });
    +    }
    +  });
    +
       it("rewrites inbound absolute paths into sandbox media/inbound", async () => {
         const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
         const agentDir = path.join(stateDir, "agent");
    
  • src/agents/tools/image-tool.ts+22 1 modified
    @@ -12,6 +12,7 @@ import { runWithImageModelFallback } from "../model-fallback.js";
     import { resolveConfiguredModelRef } from "../model-selection.js";
     import { ensureOpenClawModelsJson } from "../models-config.js";
     import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
    +import { assertSandboxPath } from "../sandbox-paths.js";
     import type { SandboxFsBridge } from "../sandbox/fs-bridge.js";
     import { normalizeWorkspaceDir } from "../workspace-dir.js";
     import type { AnyAgentTool } from "./common.js";
    @@ -207,6 +208,7 @@ function buildImageContext(
     type ImageSandboxConfig = {
       root: string;
       bridge: SandboxFsBridge;
    +  workspaceOnly?: boolean;
     };
     
     async function resolveSandboxedImagePath(params: {
    @@ -220,6 +222,13 @@ async function resolveSandboxedImagePath(params: {
           filePath,
           cwd: params.sandbox.root,
         });
    +    if (params.sandbox.workspaceOnly) {
    +      await assertSandboxPath({
    +        filePath: resolved.hostPath,
    +        cwd: params.sandbox.root,
    +        root: params.sandbox.root,
    +      });
    +    }
         return { resolved: resolved.hostPath };
       } catch (err) {
         const name = path.basename(filePath);
    @@ -239,6 +248,13 @@ async function resolveSandboxedImagePath(params: {
           filePath: candidateRel,
           cwd: params.sandbox.root,
         });
    +    if (params.sandbox.workspaceOnly) {
    +      await assertSandboxPath({
    +        filePath: out.hostPath,
    +        cwd: params.sandbox.root,
    +        root: params.sandbox.root,
    +      });
    +    }
         return { resolved: out.hostPath, rewrittenFrom: filePath };
       }
     }
    @@ -336,6 +352,7 @@ export function createImageTool(options?: {
       agentDir?: string;
       workspaceDir?: string;
       sandbox?: ImageSandboxConfig;
    +  workspaceOnly?: boolean;
       /** If true, the model has native vision capability and images in the prompt are auto-injected */
       modelHasVision?: boolean;
     }): AnyAgentTool | null {
    @@ -444,7 +461,11 @@ export function createImageTool(options?: {
     
           const sandboxConfig =
             options?.sandbox && options?.sandbox.root.trim()
    -          ? { root: options.sandbox.root.trim(), bridge: options.sandbox.bridge }
    +          ? {
    +              root: options.sandbox.root.trim(),
    +              bridge: options.sandbox.bridge,
    +              workspaceOnly: options.workspaceOnly === true,
    +            }
               : null;
     
           // MARK: - Load and resolve each image
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.