mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix: harden ACP secret handling and exec preflight boundaries
This commit is contained in:
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup.
|
||||
- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting.
|
||||
- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off.
|
||||
- Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage.
|
||||
- Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift.
|
||||
- Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance.
|
||||
|
||||
@@ -21,6 +21,9 @@ openclaw acp
|
||||
# Remote Gateway
|
||||
openclaw acp --url wss://gateway-host:18789 --token <token>
|
||||
|
||||
# Remote Gateway (token from file)
|
||||
openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
|
||||
|
||||
# Attach to an existing session key
|
||||
openclaw acp --session agent:main:main
|
||||
|
||||
@@ -40,7 +43,7 @@ It spawns the ACP bridge and lets you type prompts interactively.
|
||||
openclaw acp client
|
||||
|
||||
# Point the spawned bridge at a remote Gateway
|
||||
openclaw acp client --server-args --url wss://gateway-host:18789 --token <token>
|
||||
openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
|
||||
|
||||
# Override the server command (default: openclaw)
|
||||
openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001
|
||||
@@ -66,6 +69,8 @@ Example direct run (no config write):
|
||||
|
||||
```bash
|
||||
openclaw acp --url wss://gateway-host:18789 --token <token>
|
||||
# preferred for local process safety
|
||||
openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token
|
||||
```
|
||||
|
||||
## Selecting agents
|
||||
@@ -153,7 +158,9 @@ Learn more about session keys at [/concepts/session](/concepts/session).
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
||||
- `--token <token>`: Gateway auth token.
|
||||
- `--token-file <path>`: read Gateway auth token from file.
|
||||
- `--password <password>`: Gateway auth password.
|
||||
- `--password-file <path>`: read Gateway auth password from file.
|
||||
- `--session <key>`: default session key.
|
||||
- `--session-label <label>`: default session label to resolve.
|
||||
- `--require-existing`: fail if the session key/label does not exist.
|
||||
@@ -161,6 +168,11 @@ Learn more about session keys at [/concepts/session](/concepts/session).
|
||||
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
|
||||
- `--verbose, -v`: verbose logging to stderr.
|
||||
|
||||
Security note:
|
||||
|
||||
- `--token` and `--password` can be visible in local process listings on some systems.
|
||||
- Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`).
|
||||
|
||||
### `acp client` options
|
||||
|
||||
- `--cwd <dir>`: working directory for the ACP session.
|
||||
|
||||
@@ -76,6 +76,7 @@ If more than one person can DM your bot:
|
||||
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||
- **Plugins** (extensions exist without an explicit allowlist).
|
||||
- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
|
||||
- **Runtime expectation drift** (for example `tools.exec.host="sandbox"` while sandbox mode is off, which runs directly on the gateway host).
|
||||
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
||||
|
||||
If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
|
||||
@@ -107,26 +108,28 @@ When the audit prints findings, treat this as a priority order:
|
||||
|
||||
High-signal `checkId` values you will most likely see in real deployments (not exhaustive):
|
||||
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| -------------------------------------------- | ------------- | -------------------------------------------------------- | ------------------------------------------------ | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.insecure_auth` | critical | Token-only over HTTP, no device identity | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| --------------------------------------------- | ------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.insecure_auth` | critical | Token-only over HTTP, no device identity | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
|
||||
## Control UI over HTTP
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ Notes:
|
||||
- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on
|
||||
the gateway host (no container) and **does not require approvals**. To require approvals, run with
|
||||
`host=gateway` and configure exec approvals (or enable sandboxing).
|
||||
- Script preflight checks (for common Python/Node shell-syntax mistakes) only inspect files inside the
|
||||
effective `workdir` boundary. If a script path resolves outside `workdir`, preflight is skipped for
|
||||
that file.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
22
src/acp/secret-file.ts
Normal file
22
src/acp/secret-file.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import fs from "node:fs";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function readSecretFromFile(filePath: string, label: string): string {
|
||||
const resolvedPath = resolveUserPath(filePath.trim());
|
||||
if (!resolvedPath) {
|
||||
throw new Error(`${label} file path is empty.`);
|
||||
}
|
||||
let raw = "";
|
||||
try {
|
||||
raw = fs.readFileSync(resolvedPath, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
const secret = raw.trim();
|
||||
if (!secret) {
|
||||
throw new Error(`${label} file at ${resolvedPath} is empty.`);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import type { AcpServerOptions } from "./types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { isMainModule } from "../infra/is-main.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { readSecretFromFile } from "./secret-file.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
import type { AcpServerOptions } from "./types.js";
|
||||
|
||||
export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
@@ -95,6 +96,8 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
|
||||
|
||||
function parseArgs(args: string[]): AcpServerOptions {
|
||||
const opts: AcpServerOptions = {};
|
||||
let tokenFile: string | undefined;
|
||||
let passwordFile: string | undefined;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "--url" || arg === "--gateway-url") {
|
||||
@@ -107,11 +110,21 @@ function parseArgs(args: string[]): AcpServerOptions {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--token-file" || arg === "--gateway-token-file") {
|
||||
tokenFile = args[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--password" || arg === "--gateway-password") {
|
||||
opts.gatewayPassword = args[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--password-file" || arg === "--gateway-password-file") {
|
||||
passwordFile = args[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--session") {
|
||||
opts.defaultSessionKey = args[i + 1];
|
||||
i += 1;
|
||||
@@ -143,6 +156,18 @@ function parseArgs(args: string[]): AcpServerOptions {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (opts.gatewayToken?.trim() && tokenFile?.trim()) {
|
||||
throw new Error("Use either --token or --token-file.");
|
||||
}
|
||||
if (opts.gatewayPassword?.trim() && passwordFile?.trim()) {
|
||||
throw new Error("Use either --password or --password-file.");
|
||||
}
|
||||
if (tokenFile?.trim()) {
|
||||
opts.gatewayToken = readSecretFromFile(tokenFile, "Gateway token");
|
||||
}
|
||||
if (passwordFile?.trim()) {
|
||||
opts.gatewayPassword = readSecretFromFile(passwordFile, "Gateway password");
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -154,7 +179,9 @@ Gateway-backed ACP server for IDE integration.
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL
|
||||
--token <token> Gateway auth token
|
||||
--token-file <path> Read gateway auth token from file
|
||||
--password <password> Gateway auth password
|
||||
--password-file <path> Read gateway auth password from file
|
||||
--session <key> Default session key (e.g. "agent:main:main")
|
||||
--session-label <label> Default session label to resolve
|
||||
--require-existing Fail if the session key/label does not exist
|
||||
@@ -166,7 +193,18 @@ Options:
|
||||
}
|
||||
|
||||
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes("--token") || argv.includes("--gateway-token")) {
|
||||
console.error(
|
||||
"Warning: --token can be exposed via process listings. Prefer --token-file or OPENCLAW_GATEWAY_TOKEN.",
|
||||
);
|
||||
}
|
||||
if (argv.includes("--password") || argv.includes("--gateway-password")) {
|
||||
console.error(
|
||||
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
|
||||
);
|
||||
}
|
||||
const opts = parseArgs(argv);
|
||||
serveAcpGateway(opts).catch((err) => {
|
||||
console.error(String(err));
|
||||
process.exit(1);
|
||||
|
||||
56
src/acp/translator.prompt-prefix.test.ts
Normal file
56
src/acp/translator.prompt-prefix.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { AgentSideConnection, PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import { createInMemorySessionStore } from "./session.js";
|
||||
import { AcpGatewayAgent } from "./translator.js";
|
||||
|
||||
function createConnection(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
describe("acp prompt cwd prefix", () => {
|
||||
it("redacts home directory in prompt prefix", async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const homeCwd = path.join(os.homedir(), "openclaw-test");
|
||||
sessionStore.createSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
cwd: homeCwd,
|
||||
});
|
||||
|
||||
const requestSpy = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("stop-after-send");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const gateway = {
|
||||
request: requestSpy,
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const agent = new AcpGatewayAgent(createConnection(), gateway, {
|
||||
sessionStore,
|
||||
prefixCwd: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId: "session-1",
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
_meta: {},
|
||||
} as unknown as PromptRequest),
|
||||
).rejects.toThrow("stop-after-send");
|
||||
|
||||
expect(requestSpy).toHaveBeenCalledWith(
|
||||
"chat.send",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("[Working directory: ~/openclaw-test]"),
|
||||
}),
|
||||
{ expectFinal: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
@@ -20,6 +19,7 @@ import type {
|
||||
StopReason,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import type { SessionsListResult } from "../gateway/session-utils.js";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createFixedWindowRateLimiter,
|
||||
type FixedWindowRateLimiter,
|
||||
} from "../infra/fixed-window-rate-limit.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { getAvailableCommands } from "./commands.js";
|
||||
import {
|
||||
extractAttachmentsFromPrompt,
|
||||
@@ -263,7 +264,8 @@ export class AcpGatewayAgent implements Agent {
|
||||
const userText = extractTextFromPrompt(params.prompt);
|
||||
const attachments = extractAttachmentsFromPrompt(params.prompt);
|
||||
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
|
||||
const message = prefixCwd ? `[Working directory: ${session.cwd}]\n\n${userText}` : userText;
|
||||
const displayCwd = shortenHomePath(session.cwd);
|
||||
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
|
||||
|
||||
return new Promise<PromptResponse>((resolve, reject) => {
|
||||
this.pendingPrompts.set(params.sessionId, {
|
||||
|
||||
@@ -61,4 +61,25 @@ describe("exec script preflight", () => {
|
||||
/exec preflight: (detected likely shell variable injection|JS file starts with shell syntax)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips preflight file reads for script paths outside the workdir", async () => {
|
||||
if (isWin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-exec-preflight-parent-"));
|
||||
const outsidePath = path.join(parent, "outside.js");
|
||||
const workdir = path.join(parent, "workdir");
|
||||
await fs.mkdir(workdir, { recursive: true });
|
||||
await fs.writeFile(outsidePath, "const value = $DM_JSON;", "utf-8");
|
||||
|
||||
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
|
||||
|
||||
const result = await tool.execute("call-outside", {
|
||||
command: "node ../outside.js",
|
||||
workdir,
|
||||
});
|
||||
const text = result.content.find((block) => block.type === "text")?.text ?? "";
|
||||
expect(text).not.toMatch(/exec preflight:/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type {
|
||||
ExecElevatedDefaults,
|
||||
ExecToolDefaults,
|
||||
ExecToolDetails,
|
||||
} from "./bash-tools.exec-types.js";
|
||||
import { type ExecHost, maxAsk, minSecurity, resolveSafeBins } from "../infra/exec-approvals.js";
|
||||
import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
|
||||
import {
|
||||
@@ -28,11 +33,6 @@ import {
|
||||
execSchema,
|
||||
validateHostEnv,
|
||||
} from "./bash-tools.exec-runtime.js";
|
||||
import type {
|
||||
ExecElevatedDefaults,
|
||||
ExecToolDefaults,
|
||||
ExecToolDetails,
|
||||
} from "./bash-tools.exec-types.js";
|
||||
import {
|
||||
buildSandboxEnv,
|
||||
clampWithDefault,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
resolveWorkdir,
|
||||
truncateMiddle,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
|
||||
export type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
export type {
|
||||
@@ -91,6 +92,11 @@ async function validateScriptFileForShellBleed(params: {
|
||||
// Best-effort: only validate if file exists and is reasonably small.
|
||||
let stat: { isFile(): boolean; size: number };
|
||||
try {
|
||||
await assertSandboxPath({
|
||||
filePath: absPath,
|
||||
cwd: params.workdir,
|
||||
root: params.workdir,
|
||||
});
|
||||
stat = await fs.stat(absPath);
|
||||
} catch {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runAcpClientInteractive = vi.fn(async (_opts: unknown) => {});
|
||||
@@ -42,4 +45,64 @@ describe("acp cli option collisions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads gateway token/password from files", async () => {
|
||||
const { registerAcpCli } = await import("./acp-cli.js");
|
||||
const program = new Command();
|
||||
registerAcpCli(program);
|
||||
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-cli-"));
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
const passwordFile = path.join(dir, "password.txt");
|
||||
await fs.writeFile(tokenFile, "tok_file\n", "utf8");
|
||||
await fs.writeFile(passwordFile, "pw_file\n", "utf8");
|
||||
|
||||
await program.parseAsync(["acp", "--token-file", tokenFile, "--password-file", passwordFile], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(serveAcpGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayToken: "tok_file",
|
||||
gatewayPassword: "pw_file",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mixed secret flags and file flags", async () => {
|
||||
const { registerAcpCli } = await import("./acp-cli.js");
|
||||
const program = new Command();
|
||||
registerAcpCli(program);
|
||||
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-cli-"));
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
await fs.writeFile(tokenFile, "tok_file\n", "utf8");
|
||||
|
||||
await program.parseAsync(["acp", "--token", "tok_inline", "--token-file", tokenFile], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(serveAcpGateway).not.toHaveBeenCalled();
|
||||
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Use either --token or --token-file/),
|
||||
);
|
||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("warns when inline secret flags are used", async () => {
|
||||
const { registerAcpCli } = await import("./acp-cli.js");
|
||||
const program = new Command();
|
||||
registerAcpCli(program);
|
||||
|
||||
await program.parseAsync(["acp", "--token", "tok_inline", "--password", "pw_inline"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/--token can be exposed via process listings/),
|
||||
);
|
||||
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/--password can be exposed via process listings/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
import type { Command } from "commander";
|
||||
import { runAcpClientInteractive } from "../acp/client.js";
|
||||
import { readSecretFromFile } from "../acp/secret-file.js";
|
||||
import { serveAcpGateway } from "../acp/server.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { inheritOptionFromParent } from "./command-options.js";
|
||||
|
||||
function resolveSecretOption(params: {
|
||||
direct?: string;
|
||||
file?: string;
|
||||
directFlag: string;
|
||||
fileFlag: string;
|
||||
label: string;
|
||||
}) {
|
||||
const direct = params.direct?.trim();
|
||||
const file = params.file?.trim();
|
||||
if (direct && file) {
|
||||
throw new Error(`Use either ${params.directFlag} or ${params.fileFlag} for ${params.label}.`);
|
||||
}
|
||||
if (file) {
|
||||
return readSecretFromFile(file, params.label);
|
||||
}
|
||||
return direct || undefined;
|
||||
}
|
||||
|
||||
function warnSecretCliFlag(flag: "--token" | "--password") {
|
||||
defaultRuntime.error(
|
||||
`Warning: ${flag} can be exposed via process listings. Prefer ${flag}-file or environment variables.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function registerAcpCli(program: Command) {
|
||||
const acp = program.command("acp").description("Run an ACP bridge backed by the Gateway");
|
||||
|
||||
acp
|
||||
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--token-file <path>", "Read gateway token from file")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option("--password-file <path>", "Read gateway password from file")
|
||||
.option("--session <key>", "Default session key (e.g. agent:main:main)")
|
||||
.option("--session-label <label>", "Default session label to resolve")
|
||||
.option("--require-existing", "Fail if the session key/label does not exist", false)
|
||||
@@ -25,10 +52,30 @@ export function registerAcpCli(program: Command) {
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const gatewayToken = resolveSecretOption({
|
||||
direct: opts.token as string | undefined,
|
||||
file: opts.tokenFile as string | undefined,
|
||||
directFlag: "--token",
|
||||
fileFlag: "--token-file",
|
||||
label: "Gateway token",
|
||||
});
|
||||
const gatewayPassword = resolveSecretOption({
|
||||
direct: opts.password as string | undefined,
|
||||
file: opts.passwordFile as string | undefined,
|
||||
directFlag: "--password",
|
||||
fileFlag: "--password-file",
|
||||
label: "Gateway password",
|
||||
});
|
||||
if (opts.token) {
|
||||
warnSecretCliFlag("--token");
|
||||
}
|
||||
if (opts.password) {
|
||||
warnSecretCliFlag("--password");
|
||||
}
|
||||
await serveAcpGateway({
|
||||
gatewayUrl: opts.url as string | undefined,
|
||||
gatewayToken: opts.token as string | undefined,
|
||||
gatewayPassword: opts.password as string | undefined,
|
||||
gatewayToken,
|
||||
gatewayPassword,
|
||||
defaultSessionKey: opts.session as string | undefined,
|
||||
defaultSessionLabel: opts.sessionLabel as string | undefined,
|
||||
requireExistingSession: Boolean(opts.requireExisting),
|
||||
|
||||
@@ -235,6 +235,58 @@ describe("security audit", () => {
|
||||
expect(hasFinding(res, "gateway.auth_no_rate_limit")).toBe(false);
|
||||
});
|
||||
|
||||
it("warns when exec host is explicitly sandbox while sandbox mode is off", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "sandbox",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await audit(cfg);
|
||||
|
||||
expect(hasFinding(res, "tools.exec.host_sandbox_no_sandbox_defaults", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when an agent sets exec host=sandbox with sandbox mode off", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
host: "sandbox",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const res = await audit(cfg);
|
||||
|
||||
expect(hasFinding(res, "tools.exec.host_sandbox_no_sandbox_agents", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when loopback control UI lacks trusted proxies", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||
import { resolveBrowserControlAuth } from "../browser/control-auth.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
@@ -36,7 +38,6 @@ import {
|
||||
inspectPathPermissions,
|
||||
} from "./audit-fs.js";
|
||||
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityAuditSeverity = "info" | "warn" | "critical";
|
||||
|
||||
@@ -566,6 +567,54 @@ function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const globalExecHost = cfg.tools?.exec?.host;
|
||||
const defaultSandboxMode = resolveSandboxConfigForAgent(cfg).mode;
|
||||
const defaultHostIsExplicitSandbox = globalExecHost === "sandbox";
|
||||
|
||||
if (defaultHostIsExplicitSandbox && defaultSandboxMode === "off") {
|
||||
findings.push({
|
||||
checkId: "tools.exec.host_sandbox_no_sandbox_defaults",
|
||||
severity: "warn",
|
||||
title: "Exec host is sandbox but sandbox mode is off",
|
||||
detail:
|
||||
"tools.exec.host is explicitly set to sandbox while agents.defaults.sandbox.mode=off. " +
|
||||
"In this mode, exec runs directly on the gateway host.",
|
||||
remediation:
|
||||
'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) or set tools.exec.host to "gateway" with approvals.',
|
||||
});
|
||||
}
|
||||
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const riskyAgents = agents
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.id === "string" &&
|
||||
entry.tools?.exec?.host === "sandbox" &&
|
||||
resolveSandboxConfigForAgent(cfg, entry.id).mode === "off",
|
||||
)
|
||||
.map((entry) => entry.id)
|
||||
.slice(0, 5);
|
||||
|
||||
if (riskyAgents.length > 0) {
|
||||
findings.push({
|
||||
checkId: "tools.exec.host_sandbox_no_sandbox_agents",
|
||||
severity: "warn",
|
||||
title: "Agent exec host uses sandbox while sandbox mode is off",
|
||||
detail:
|
||||
`agents.list.*.tools.exec.host is set to sandbox for: ${riskyAgents.join(", ")}. ` +
|
||||
"With sandbox mode off, exec runs directly on the gateway host.",
|
||||
remediation:
|
||||
'Enable sandbox mode for these agents (`agents.list[].sandbox.mode`) or set their tools.exec.host to "gateway".',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
async function maybeProbeGateway(params: {
|
||||
cfg: OpenClawConfig;
|
||||
timeoutMs: number;
|
||||
@@ -621,6 +670,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
findings.push(...collectBrowserControlFindings(cfg, env));
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
findings.push(...collectExecRuntimeFindings(cfg));
|
||||
findings.push(...collectHooksHardeningFindings(cfg, env));
|
||||
findings.push(...collectGatewayHttpNoAuthFindings(cfg, env));
|
||||
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
|
||||
|
||||
Reference in New Issue
Block a user