Skip to content

Commit df00747

Browse files
fix(exec): reject invalid host targets (#74468)
* fix(exec): reject invalid host targets * docs(changelog): credit exec host validation contributor --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 9a0b43c commit df00747

7 files changed

Lines changed: 78 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222

2323
### Fixes
2424

25+
- Exec: reject invalid per-call `host` values instead of silently falling back to the default target, so hostname-like values fail before commands run. Fixes #74426. Thanks @scr00ge-00 and @vyctorbrzezowski.
2526
- Build/Gateway: route restart, shutdown, respawn, diagnostics, command-queue cleanup, and runtime cleanup through one stable gateway lifecycle runtime entry so rebuilt packages do not strand long-running gateways on stale hashed chunks. Carries forward #73964. Thanks @pashpashpash.
2627
- Memory/wiki: keep broad shared-source and generated related-link blocks from turning every page into a search hit, cap noisy backlinks, support all-term searches such as people-routing queries, and prefer readable page body snippets over generated metadata. Thanks @vincentkoc.
2728
- Cron/Gateway: abort and bounded-clean up timed-out isolated agent turns before recording the timeout, so stale cron sessions cannot leave Discord or other chat lanes stuck in `processing` after a timeout. Thanks @vincentkoc.

docs/tools/exec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Request elevated mode — escape the sandbox onto the configured host path. `sec
6363
Notes:
6464

6565
- `host` defaults to `auto`: sandbox when sandbox runtime is active for the session, otherwise gateway.
66+
- `host` only accepts `auto`, `sandbox`, `gateway`, or `node`. It is not a hostname selector; hostname-like values are rejected before the command runs.
6667
- `auto` is the default routing strategy, not a wildcard. Per-call `host=node` is allowed from `auto`; per-call `host=gateway` is only allowed when no sandbox runtime is active.
6768
- With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox.
6869
- `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider.

src/agents/bash-tools.exec-foreground-failures.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,32 @@ describe("exec foreground failures", () => {
5050
});
5151
expect((result.details as { durationMs?: number }).durationMs).toEqual(expect.any(Number));
5252
});
53+
54+
it("rejects invalid host values before launching a command", async () => {
55+
const tool = createExecTool({
56+
security: "full",
57+
ask: "off",
58+
allowBackground: false,
59+
});
60+
for (const testCase of [
61+
{
62+
host: "spark-ff13",
63+
message: 'Invalid exec host "spark-ff13". Allowed values: auto, sandbox, gateway, node.',
64+
},
65+
{
66+
host: 42,
67+
message:
68+
"Invalid exec host value type number. Allowed values: auto, sandbox, gateway, node.",
69+
},
70+
]) {
71+
const malformedArgs = {
72+
command: "echo should-not-run",
73+
host: testCase.host,
74+
} as unknown as Parameters<typeof tool.execute>[1];
75+
76+
await expect(tool.execute("call-invalid-host", malformedArgs)).rejects.toThrow(
77+
testCase.message,
78+
);
79+
}
80+
});
5381
});

src/agents/bash-tools.exec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
loadExecApprovals,
99
maxAsk,
1010
minSecurity,
11+
requireValidExecTarget,
1112
} from "../infra/exec-approvals.js";
1213
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
1314
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
@@ -38,7 +39,6 @@ import {
3839
applyShellPath,
3940
normalizeExecAsk,
4041
normalizeExecSecurity,
41-
normalizeExecTarget,
4242
normalizePathPrepend,
4343
resolveExecTarget,
4444
resolveApprovalRunningNoticeMs,
@@ -1543,9 +1543,10 @@ export function createExecTool(
15431543
if (elevatedRequested) {
15441544
logInfo(`exec: elevated command ${truncateMiddle(params.command, 120)}`);
15451545
}
1546+
const requestedTarget = requireValidExecTarget(params.host);
15461547
const target = resolveExecTarget({
15471548
configuredTarget: defaults?.host,
1548-
requestedTarget: normalizeExecTarget(params.host),
1549+
requestedTarget,
15491550
elevatedRequested,
15501551
sandboxAvailable: Boolean(defaults?.sandbox),
15511552
});

src/agents/bash-tools.schemas.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { Type } from "typebox";
2+
import { optionalStringEnum } from "./schema/typebox.js";
3+
4+
const EXEC_TOOL_HOST_VALUES = ["auto", "sandbox", "gateway", "node"] as const;
25

36
export const execSchema = Type.Object({
47
command: Type.String({ description: "Shell command to execute" }),
@@ -26,11 +29,9 @@ export const execSchema = Type.Object({
2629
description: "Run on the host with elevated permissions (if allowed)",
2730
}),
2831
),
29-
host: Type.Optional(
30-
Type.String({
31-
description: "Exec host/target (auto|sandbox|gateway|node).",
32-
}),
33-
),
32+
host: optionalStringEnum(EXEC_TOOL_HOST_VALUES, {
33+
description: "Exec host/target (auto|sandbox|gateway|node).",
34+
}),
3435
security: Type.Optional(
3536
Type.String({
3637
description: "Exec security mode (deny|allowlist|full).",

src/infra/exec-approvals-policy.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
hasDurableExecApproval,
1515
maxAsk,
1616
minSecurity,
17+
requireValidExecTarget,
1718
type ExecApprovalsFile,
1819
normalizeExecAsk,
1920
normalizeExecHost,
@@ -73,6 +74,18 @@ describe("exec approvals policy helpers", () => {
7374
expect(normalizeExecTarget(raw)).toBe(expected);
7475
});
7576

77+
it("requires direct exec target requests to use the closed host set", () => {
78+
expect(requireValidExecTarget(" gateway ")).toBe("gateway");
79+
expect(requireValidExecTarget("")).toBe(null);
80+
expect(requireValidExecTarget(undefined)).toBe(null);
81+
expect(() => requireValidExecTarget("spark-ff13")).toThrow(
82+
'Invalid exec host "spark-ff13". Allowed values: auto, sandbox, gateway, node.',
83+
);
84+
expect(() => requireValidExecTarget(42)).toThrow(
85+
"Invalid exec host value type number. Allowed values: auto, sandbox, gateway, node.",
86+
);
87+
});
88+
7689
it.each([
7790
{ raw: " allowlist ", expected: "allowlist" },
7891
{ raw: "FULL", expected: "full" },

src/infra/exec-approvals.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export type ExecTarget = "auto" | ExecHost;
2222
export type ExecSecurity = "deny" | "allowlist" | "full";
2323
export type ExecAsk = "off" | "on-miss" | "always";
2424

25+
export const EXEC_TARGET_VALUES: readonly ExecTarget[] = ["auto", "sandbox", "gateway", "node"];
26+
2527
export function normalizeExecHost(value?: string | null): ExecHost | null {
2628
const normalized = normalizeOptionalLowercaseString(value);
2729
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
@@ -38,6 +40,30 @@ export function normalizeExecTarget(value?: string | null): ExecTarget | null {
3840
return normalizeExecHost(normalized);
3941
}
4042

43+
export function requireValidExecTarget(value?: unknown): ExecTarget | null {
44+
if (value == null) {
45+
return null;
46+
}
47+
if (typeof value !== "string") {
48+
throw new Error(
49+
`Invalid exec host value type ${typeof value}. Allowed values: ${EXEC_TARGET_VALUES.join(
50+
", ",
51+
)}.`,
52+
);
53+
}
54+
const normalized = normalizeOptionalLowercaseString(value);
55+
if (!normalized) {
56+
return null;
57+
}
58+
const target = normalizeExecTarget(normalized);
59+
if (target) {
60+
return target;
61+
}
62+
throw new Error(
63+
`Invalid exec host "${value}". Allowed values: ${EXEC_TARGET_VALUES.join(", ")}.`,
64+
);
65+
}
66+
4167
/** Coerce a raw JSON field to string, returning undefined for non-string types. */
4268
const toStringOrUndefined = readStringValue;
4369

0 commit comments

Comments
 (0)