Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
99bc953
fix(onboard): detect web-search credential exposed in sandbox env
yimoj Jul 27, 2026
bf431ac
merge: sync origin/main for installer-hash trust pin (#7425)
yimoj Jul 27, 2026
f9625a6
fix(onboard): harden web-search secret-boundary probe per review
yimoj Jul 27, 2026
0356b45
test(e2e): scan every readable sandbox process env for the Brave key
yimoj Jul 27, 2026
829a2b1
fix(onboard): block exposed web search credentials
prekshivyas Jul 29, 2026
5e1e20c
merge(main): refresh PR #7619
prekshivyas Jul 29, 2026
a7922d9
fix(onboard): preserve credential gate after refresh
prekshivyas Jul 29, 2026
f590917
docs(security): clarify web search isolation failure
prekshivyas Jul 29, 2026
9e5144c
fix(onboard): fail closed on web search boundary checks
prekshivyas Jul 29, 2026
031368e
docs(security): align web search boundary comments
prekshivyas Jul 29, 2026
5baa43a
chore(onboard): keep entrypoint growth neutral
prekshivyas Jul 29, 2026
2397661
Merge branch 'main' into fix/7425-brave-secret-agent-leak
prekshivyas Aug 4, 2026
8eb5c41
merge(main): refresh PR 7619 checks
prekshivyas Aug 4, 2026
ab1595f
test(security): avoid sandbox secret fixtures
prekshivyas Aug 4, 2026
5ba5038
merge(main): refresh PR 7619 checks
prekshivyas Aug 4, 2026
1040c65
fix(security): use keyed live-e2e probes
prekshivyas Aug 4, 2026
fe59ab6
merge(main): refresh PR 7619 checks
prekshivyas Aug 4, 2026
13692e5
Merge branch 'main' into fix/7425-brave-secret-agent-leak
prekshivyas Aug 4, 2026
cb5e5a5
Merge branch 'main' into fix/7425-brave-secret-agent-leak
prekshivyas Aug 4, 2026
7fcb71f
test(e2e): classify Brave credential boundary
prekshivyas Aug 4, 2026
2f843f8
Merge remote-tracking branch 'origin/fix/7425-brave-secret-agent-leak…
prekshivyas Aug 4, 2026
2d767c9
Merge remote-tracking branch 'origin/main' into codex/pr7619-validation
prekshivyas Aug 4, 2026
a5dcc4b
merge: resolve conflicts with main
github-actions[bot] Aug 4, 2026
2e10b97
Merge branch 'main' into fix/7425-brave-secret-agent-leak
prekshivyas Aug 5, 2026
20680ba
Merge branch 'main' into fix/7425-brave-secret-agent-leak
cv Aug 5, 2026
064f813
Merge branch 'main' into fix/7425-brave-secret-agent-leak
prekshivyas Aug 5, 2026
7950d8e
merge(main): refresh PR #7619
cjagwani Aug 5, 2026
ebe794f
Merge branch 'main' into fix/7425-brave-secret-agent-leak
prekshivyas Aug 6, 2026
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
139 changes: 128 additions & 11 deletions src/lib/onboard/web-search-verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@

import { describe, expect, it, vi } from "vitest";

import { verifyWebSearchInsideSandbox, type WebSearchVerifyDeps } from "./web-search-verify";
import {
classifyWebSearchEnvBoundary,
verifyWebSearchInsideSandbox,
type WebSearchVerifyDeps,
} from "./web-search-verify";

function deps(output: string | null | Array<string | null>) {
const outputs = Array.isArray(output) ? [...output] : [output];
Expand All @@ -19,14 +23,18 @@ function deps(output: string | null | Array<string | null>) {

describe("verifyWebSearchInsideSandbox", () => {
it("verifies Hermes Tavily egress through JSON body credential rewriting", () => {
// Between the config read and the egress probe the secret-boundary check
// classifies the env var in-sandbox; a null/absent sentinel means no raw
// key is exposed.
const d = deps([
"web:\n backend: tavily\n",
"absent",
JSON.stringify({ results: [{ title: "NVIDIA" }] }) + "\nHTTP_STATUS:200\n",
]);

verifyWebSearchInsideSandbox("alpha", { name: "hermes" }, d);

expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2);
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(3);
expect(d.runCaptureOpenshell.mock.calls[0][0]).toEqual([
"sandbox",
"exec",
Expand All @@ -36,7 +44,19 @@ describe("verifyWebSearchInsideSandbox", () => {
"cat",
"/sandbox/.hermes/config.yaml",
]);
expect(d.runCaptureOpenshell.mock.calls[1][0]).toEqual([
// The boundary probe classifies in-sandbox and returns only a sentinel.
expect(d.runCaptureOpenshell.mock.calls[1][0].slice(0, 7)).toEqual([
"sandbox",
"exec",
"-n",
"alpha",
"--",
"sh",
"-lc",
]);
expect(d.runCaptureOpenshell.mock.calls[1][0][7]).toContain("printenv TAVILY_API_KEY");
expect(d.runCaptureOpenshell.mock.calls[1][0][7]).not.toContain("cat ");
expect(d.runCaptureOpenshell.mock.calls[2][0]).toEqual([
"sandbox",
"exec",
"-n",
Expand Down Expand Up @@ -93,13 +113,24 @@ describe("verifyWebSearchInsideSandbox", () => {
},
},
}),
"absent",
JSON.stringify({ web: { results: [{ title: "NVIDIA" }] } }) + "\nHTTP_STATUS:200\n",
]);

verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d);

expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2);
expect(d.runCaptureOpenshell.mock.calls[1][0]).toEqual([
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(3);
expect(d.runCaptureOpenshell.mock.calls[1][0].slice(0, 7)).toEqual([
"sandbox",
"exec",
"-n",
"alpha",
"--",
"sh",
"-lc",
]);
expect(d.runCaptureOpenshell.mock.calls[1][0][7]).toContain("printenv BRAVE_API_KEY");
expect(d.runCaptureOpenshell.mock.calls[2][0]).toEqual([
"sandbox",
"exec",
"-n",
Expand All @@ -125,13 +156,14 @@ describe("verifyWebSearchInsideSandbox", () => {
},
},
}),
null,
JSON.stringify({ results: [{ title: "NVIDIA" }] }) + "\nHTTP_STATUS:200\n",
]);

verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d);

expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2);
expect(d.runCaptureOpenshell.mock.calls[1][0]).toEqual([
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(3);
expect(d.runCaptureOpenshell.mock.calls[2][0]).toEqual([
"sandbox",
"exec",
"-n",
Expand All @@ -141,7 +173,7 @@ describe("verifyWebSearchInsideSandbox", () => {
"-lc",
expect.stringContaining("Authorization: Bearer openshell:resolve:env:TAVILY_API_KEY"),
]);
expect(d.runCaptureOpenshell.mock.calls[1][0][7]).toContain("https://api.tavily.com/search");
expect(d.runCaptureOpenshell.mock.calls[2][0][7]).toContain("https://api.tavily.com/search");
expect(d.log).toHaveBeenCalledWith(" ✓ Tavily Search egress verified inside sandbox");
});

Expand All @@ -158,6 +190,7 @@ describe("verifyWebSearchInsideSandbox", () => {
},
},
}),
null,
JSON.stringify({ results: [] }) + "\nHTTP_STATUS:200\n",
]);

Expand All @@ -182,12 +215,13 @@ describe("verifyWebSearchInsideSandbox", () => {
},
},
}),
null,
JSON.stringify({ web: { results: [{ title: "NVIDIA" }] } }) + "\nHTTP_STATUS:200\n",
]);

verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d);

expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2);
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(3);
expect(d.log).toHaveBeenCalledWith(" ✓ Brave Search egress verified inside sandbox");
});

Expand All @@ -204,6 +238,7 @@ describe("verifyWebSearchInsideSandbox", () => {
},
},
}),
null,
'{"message":"Unauthorized"}\nHTTP_STATUS:401\n',
]);

Expand All @@ -230,12 +265,26 @@ describe("verifyWebSearchInsideSandbox", () => {
},
},
}),
null,
]);

verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d);

// Only the openclaw.json read happens — no curl probe with the raw key.
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(1);
// The config read and the sentinel-only boundary probe run, but no curl
// probe interpolates the raw key.
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(2);
expect(d.runCaptureOpenshell.mock.calls[1][0].slice(0, 7)).toEqual([
"sandbox",
"exec",
"-n",
"alpha",
"--",
"sh",
"-lc",
]);
for (const call of d.runCaptureOpenshell.mock.calls) {
expect(call[0]).not.toContain("BSA-real-looking-secret-do-not-interpolate");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
expect(d.warn).toHaveBeenCalledWith(
" ⚠ Brave Search apiKey in openclaw.json is not an OpenShell placeholder; skipping egress probe.",
);
Expand Down Expand Up @@ -271,4 +320,72 @@ describe("verifyWebSearchInsideSandbox", () => {
" ⚠ Web search verification probe failed (non-fatal).",
);
});

it("raises a security alert when the sandbox env exposes a raw Brave key (#7425)", () => {
// The in-sandbox probe returns only the `raw-secret` sentinel — never the
// key itself — so the guard does not pull the credential across the boundary.
const d = deps([
JSON.stringify({
tools: { web: { search: { enabled: true, provider: "brave" } } },
plugins: {
entries: {
brave: {
enabled: true,
config: { webSearch: { apiKey: "openshell:resolve:env:BRAVE_API_KEY" } },
},
},
},
}),
"raw-secret",
JSON.stringify({ web: { results: [{ title: "NVIDIA" }] } }) + "\nHTTP_STATUS:200\n",
]);

verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d);

expect(d.warn).toHaveBeenCalledWith(
" ✗ SECURITY: the Brave Search credential is exposed in the sandbox environment.",
);
expect(d.warn).toHaveBeenCalledWith(" nemoclaw onboard --recreate-sandbox");
// Non-fatal: egress verification still runs after the alert.
expect(d.runCaptureOpenshell).toHaveBeenCalledTimes(3);
});

it("accepts a resolve:env placeholder sentinel without a security alert", () => {
const d = deps([
JSON.stringify({
tools: { web: { search: { enabled: true, provider: "brave" } } },
plugins: {
entries: {
brave: {
enabled: true,
config: { webSearch: { apiKey: "openshell:resolve:env:BRAVE_API_KEY" } },
},
},
},
}),
"placeholder",
JSON.stringify({ web: { results: [{ title: "NVIDIA" }] } }) + "\nHTTP_STATUS:200\n",
]);

verifyWebSearchInsideSandbox("alpha", { name: "openclaw" }, d);

for (const call of d.warn.mock.calls) {
expect(String(call[0] ?? "")).not.toContain("SECURITY");
}
expect(d.log).toHaveBeenCalledWith(" ✓ Brave Search egress verified inside sandbox");
});
});

describe("classifyWebSearchEnvBoundary", () => {
it("maps in-sandbox sentinels and treats anything else as absent", () => {
expect(classifyWebSearchEnvBoundary("absent")).toBe("absent");
expect(classifyWebSearchEnvBoundary("placeholder")).toBe("placeholder");
expect(classifyWebSearchEnvBoundary("raw-secret")).toBe("raw-secret");
expect(classifyWebSearchEnvBoundary(" raw-secret\n")).toBe("raw-secret");
// A failed probe (null) or unexpected output must never raise a false alarm.
expect(classifyWebSearchEnvBoundary(null)).toBe("absent");
expect(classifyWebSearchEnvBoundary(undefined)).toBe("absent");
expect(classifyWebSearchEnvBoundary("")).toBe("absent");
expect(classifyWebSearchEnvBoundary("BSAabcdefghijklmnopqrstuvwxyz012345")).toBe("absent");
});
});
94 changes: 94 additions & 0 deletions src/lib/onboard/web-search-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

import YAML from "yaml";
import { shellQuote } from "../core/shell-quote";
import {
type WebSearchProvider,
webSearchEnvFor,
webSearchLabelFor,
} from "../inference/web-search";

export type WebSearchVerifyAgent =
| {
Expand All @@ -21,6 +26,92 @@ export type WebSearchVerifyDeps = {
warn?: (message?: string) => void;
};

export type WebSearchEnvBoundary = "absent" | "placeholder" | "raw-secret";

const WEB_SEARCH_ENV_BOUNDARY_SENTINELS: readonly WebSearchEnvBoundary[] = [
"absent",
"placeholder",
"raw-secret",
];

/**
* Shell that classifies a web-search provider's credential env var *inside* the
* sandbox and prints only a sentinel — never the value. This keeps the guard
* from pulling the very credential it is checking back across the host boundary.
* The profile-backed provider keeps the key gateway-side and rewrites it at
* egress, so the sandbox env is unset (`absent`) or carries the
* `openshell:resolve:env:<NAME>` reference (`placeholder`); a `generic`-typed
* provider instead injects the plaintext credential (`raw-secret`), which the
* agent can read and print (#7425).
*/
function buildWebSearchEnvBoundaryScript(envKey: string): string {
return [
`v="$(printenv ${envKey} 2>/dev/null || true)"`,
'case "$v" in',
" '') printf absent ;;",
" openshell:resolve:env:*) printf placeholder ;;",
" *) printf raw-secret ;;",
"esac",
].join("\n");
}

/**
* Map the in-sandbox sentinel to the typed boundary state. Anything other than a
* recognized sentinel (including a failed probe returning null) is treated as
* `absent` so a probe hiccup never raises a false security alert.
*/
export function classifyWebSearchEnvBoundary(
sentinel: string | null | undefined,
): WebSearchEnvBoundary {
const value = (sentinel ?? "").trim();
return (WEB_SEARCH_ENV_BOUNDARY_SENTINELS as readonly string[]).includes(value)
? (value as WebSearchEnvBoundary)
: "absent";
}

/**
* Runtime secret-boundary guard: assert the live sandbox container env does not
* expose the web-search provider's raw credential. `openclaw.json` inspection
* alone misses this — the key leaks through the process environment, not the
* config file. Classification runs in-sandbox and only a sentinel returns, so
* the raw value never reaches the host. On a raw-secret exposure it surfaces a
* prominent, actionable alert. Returns true when a raw credential was detected.
* Best-effort and non-fatal, matching the surrounding verification.
*/
function checkWebSearchEnvSecretBoundary(
sandboxName: string,
provider: WebSearchProvider,
deps: WebSearchVerifyDeps,
warn: (message?: string) => void,
): boolean {
const envKey = webSearchEnvFor(provider);
const probe = deps.runCaptureOpenshell(
[
"sandbox",
"exec",
"-n",
sandboxName,
"--",
"sh",
"-lc",
buildWebSearchEnvBoundaryScript(envKey),
],
{ ignoreError: true, timeout: 10_000 },
);
if (classifyWebSearchEnvBoundary(probe) !== "raw-secret") return false;

const label = webSearchLabelFor(provider);
warn("");
warn(` ✗ SECURITY: the ${label} credential is exposed in the sandbox environment.`);
warn(` ${envKey} holds a raw key inside sandbox '${sandboxName}', so the agent can read and`);
warn(" print it when asked to list environment variables or API keys.");
warn(" The credential should stay gateway-side and be resolved only at egress; a raw value");
warn(" means the provider was attached without the profile-backed rewrite. Recreate the");
warn(" sandbox to re-attach the profile-backed provider:");
warn(` ${deps.cliName()} onboard --recreate-sandbox`);
return true;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
function buildBraveEgressProbeCommand(apiKey: string): string {
return [
"curl",
Expand Down Expand Up @@ -158,6 +249,8 @@ export function verifyWebSearchInsideSandbox(
return;
}

checkWebSearchEnvSecretBoundary(sandboxName, "tavily", deps, warn);

const placeholder = "openshell:resolve:env:TAVILY_API_KEY";
const probe = deps.runCaptureOpenshell(
[
Expand Down Expand Up @@ -209,6 +302,7 @@ export function verifyWebSearchInsideSandbox(
warn(` ⚠ Web search provider '${String(provider)}' cannot be verified.`);
return;
}
checkWebSearchEnvSecretBoundary(sandboxName, provider, deps, warn);
const providerLabel = provider === "tavily" ? "Tavily Search" : "Brave Search";
// Current OpenClaw schema keeps the provider-owned apiKey under
// plugins.entries.<provider>.config.webSearch; older configs carried
Expand Down
Loading
Loading