diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b5c9a99f5..a6515638991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc. - Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc. - Security/agent tools: mark `nodes` as explicitly owner-only and document/test that `canvas` remains a shared trusted-operator surface unless a real boundary bypass exists. +- Security/exec approvals: fail closed for Ruby approval flows that use `-r`, `--require`, or `-I` so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot. - Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (`GHSA-2pwv-x786-56f8`)(#43686) Thanks @tdjackey and @vincentkoc. - Models/secrets: enforce source-managed SecretRef markers in generated `models.json` so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant. - Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (`GHSA-jv4g-m82p-2j93`)(#44089) (`GHSA-xwx2-ppv2-wx98`)(#44089) Thanks @ez-lbz and @vincentkoc. diff --git a/scripts/pr b/scripts/pr index dc0f4e2fc57..5e73cf93b65 100755 --- a/scripts/pr +++ b/scripts/pr @@ -1384,6 +1384,7 @@ validate_changelog_merge_hygiene() { prepare_gates() { local pr="$1" + local skip_test="${2:-false}" enter_worktree "$pr" false checkout_prep_branch "$pr" @@ -1418,7 +1419,9 @@ prepare_gates() { run_quiet_logged "pnpm build" ".local/gates-build.log" pnpm build run_quiet_logged "pnpm check" ".local/gates-check.log" pnpm check - if [ "$docs_only" = "true" ]; then + if [ "$skip_test" = "true" ]; then + echo "Test skipped (--no-test). Full suite deferred to Test phase." + elif [ "$docs_only" = "true" ]; then echo "Docs-only change detected with high confidence; skipping pnpm test." else run_quiet_logged "pnpm test" ".local/gates-test.log" pnpm test @@ -1987,7 +1990,7 @@ main() { prepare_validate_commit "$pr" ;; prepare-gates) - prepare_gates "$pr" + prepare_gates "$pr" "${3:-false}" ;; prepare-push) prepare_push "$pr" diff --git a/scripts/pr-prepare b/scripts/pr-prepare index 98f55df4f17..93a56845843 100755 --- a/scripts/pr-prepare +++ b/scripts/pr-prepare @@ -1,13 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -if [ "$#" -ne 2 ]; then - echo "Usage: scripts/pr-prepare " +if [ "$#" -lt 2 ]; then + echo "Usage: scripts/pr-prepare [--no-test]" exit 2 fi mode="$1" pr="$2" +shift 2 +no_test=false +for arg in "$@"; do + case "$arg" in + --no-test) no_test=true ;; + esac +done script_dir="$(cd "$(dirname "$0")" && pwd)" base="$script_dir/pr" if common_git_dir=$(git -C "$script_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then @@ -25,7 +32,11 @@ case "$mode" in exec "$base" prepare-validate-commit "$pr" ;; gates) - exec "$base" prepare-gates "$pr" + if [ "$no_test" = "true" ]; then + exec "$base" prepare-gates "$pr" true + else + exec "$base" prepare-gates "$pr" + fi ;; push) exec "$base" prepare-push "$pr" diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 010e7b5e4ef..438163d1d66 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -548,6 +548,52 @@ describe("hardenApprovedExecutionPaths", () => { }); }); + it("rejects ruby require preloads that approval cannot bind completely", () => { + withFakeRuntimeBin({ + binName: "ruby", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ruby-require-")); + try { + fs.writeFileSync(path.join(tmp, "safe.rb"), 'puts "SAFE"\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["ruby", "-r", "attacker", "./safe.rb"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + + it("rejects ruby load-path flags that can redirect module resolution after approval", () => { + withFakeRuntimeBin({ + binName: "ruby", + run: () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ruby-load-path-")); + try { + fs.writeFileSync(path.join(tmp, "safe.rb"), 'puts "SAFE"\n'); + const prepared = buildSystemRunApprovalPlan({ + command: ["ruby", "-I.", "./safe.rb"], + cwd: tmp, + }); + expect(prepared).toEqual({ + ok: false, + message: + "SYSTEM_RUN_DENIED: approval cannot safely bind this interpreter/runtime command", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + }); + }); + it("rejects shell payloads that hide mutable interpreter scripts", () => { withFakeRuntimeBin({ binName: "node", diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index afcc2963e9d..867ea9f696f 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -134,6 +134,8 @@ const NODE_OPTIONS_WITH_FILE_VALUE = new Set([ "--require", ]); +const RUBY_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-r", "--require"]); + const POSIX_SHELL_OPTIONS_WITH_VALUE = new Set([ "--init-file", "--rcfile", @@ -604,6 +606,33 @@ function resolveDenoRunScriptOperandIndex(params: { }); } +function hasRubyUnsafeApprovalFlag(argv: string[]): boolean { + let afterDoubleDash = false; + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim() ?? ""; + if (!token) { + continue; + } + if (afterDoubleDash) { + return false; + } + if (token === "--") { + afterDoubleDash = true; + continue; + } + if (token === "-I" || token === "-r") { + return true; + } + if (token.startsWith("-I") || token.startsWith("-r")) { + return true; + } + if (RUBY_UNSAFE_APPROVAL_FLAGS.has(token.toLowerCase())) { + return true; + } + } + return false; +} + function isMutableScriptRunner(executable: string): boolean { return GENERIC_MUTABLE_SCRIPT_RUNNERS.has(executable) || isInterpreterLikeSafeBin(executable); } @@ -642,6 +671,9 @@ function resolveMutableFileOperandIndex(argv: string[], cwd: string | undefined) return unwrapped.baseIndex + denoIndex; } } + if (executable === "ruby" && hasRubyUnsafeApprovalFlag(unwrapped.argv)) { + return null; + } if (!isMutableScriptRunner(executable)) { return null; }