Skip to content

Commit 969cdaa

Browse files
committed
feat: add commands.shellProfile option for custom shell profile loading
- Add shellProfile option to CommandsConfig type - Add Zod schema validation requiring absolute file path - Add help text and label for shellProfile config - Update getShellConfig() to accept and use shellProfile parameter - Support PowerShell, bash, zsh, and sh profile loading - PowerShell: source profile with . 'path'; prefix - Bash/Zsh/Sh: use source command prefix (non-interactive shells ignore --init-file) - Fix POSIX shell -c argument handling: concatenate source prefix with command - Pass shellProfile through exec tool chain - Add tests for shellProfile functionality
1 parent 7a87816 commit 969cdaa

18 files changed

Lines changed: 351 additions & 17 deletions

docs/.generated/config-baseline.json

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21600,7 +21600,10 @@
2160021600
{
2160121601
"path": "channels.matrix.accessToken",
2160221602
"kind": "channel",
21603-
"type": "string",
21603+
"type": [
21604+
"object",
21605+
"string"
21606+
],
2160421607
"required": false,
2160521608
"deprecated": false,
2160621609
"sensitive": true,
@@ -21611,6 +21614,36 @@
2161121614
"network",
2161221615
"security"
2161321616
],
21617+
"hasChildren": true
21618+
},
21619+
{
21620+
"path": "channels.matrix.accessToken.id",
21621+
"kind": "channel",
21622+
"type": "string",
21623+
"required": true,
21624+
"deprecated": false,
21625+
"sensitive": false,
21626+
"tags": [],
21627+
"hasChildren": false
21628+
},
21629+
{
21630+
"path": "channels.matrix.accessToken.provider",
21631+
"kind": "channel",
21632+
"type": "string",
21633+
"required": true,
21634+
"deprecated": false,
21635+
"sensitive": false,
21636+
"tags": [],
21637+
"hasChildren": false
21638+
},
21639+
{
21640+
"path": "channels.matrix.accessToken.source",
21641+
"kind": "channel",
21642+
"type": "string",
21643+
"required": true,
21644+
"deprecated": false,
21645+
"sensitive": false,
21646+
"tags": [],
2161421647
"hasChildren": false
2161521648
},
2161621649
{
@@ -23867,6 +23900,16 @@
2386723900
"tags": [],
2386823901
"hasChildren": false
2386923902
},
23903+
{
23904+
"path": "channels.msteams.blockStreaming",
23905+
"kind": "channel",
23906+
"type": "boolean",
23907+
"required": false,
23908+
"deprecated": false,
23909+
"sensitive": false,
23910+
"tags": [],
23911+
"hasChildren": false
23912+
},
2387023913
{
2387123914
"path": "channels.msteams.blockStreamingCoalesce",
2387223915
"kind": "channel",
@@ -38588,6 +38631,20 @@
3858838631
"help": "Allow /restart and gateway restart tool actions (default: true).",
3858938632
"hasChildren": false
3859038633
},
38634+
{
38635+
"path": "commands.shellProfile",
38636+
"kind": "core",
38637+
"type": "string",
38638+
"required": false,
38639+
"deprecated": false,
38640+
"sensitive": false,
38641+
"tags": [
38642+
"storage"
38643+
],
38644+
"label": "Shell Profile Path",
38645+
"help": "Path to a shell profile file to source before executing commands on the gateway host. When set and non-empty, PowerShell, bash, and other shells will load this profile (e.g., for custom environment variables, aliases, functions). This setting does NOT apply to sandbox execution (host=sandbox, container has isolated filesystem) or node execution (host=node, commands run directly without shell). Leave unset for default behavior without profile loading.",
38646+
"hasChildren": false
38647+
},
3859138648
{
3859238649
"path": "commands.text",
3859338650
"kind": "core",
@@ -44771,6 +44828,26 @@
4477144828
"tags": [],
4477244829
"hasChildren": false
4477344830
},
44831+
{
44832+
"path": "models.providers.*.models.*.compat.unsupportedToolSchemaKeywords",
44833+
"kind": "core",
44834+
"type": "array",
44835+
"required": false,
44836+
"deprecated": false,
44837+
"sensitive": false,
44838+
"tags": [],
44839+
"hasChildren": true
44840+
},
44841+
{
44842+
"path": "models.providers.*.models.*.compat.unsupportedToolSchemaKeywords.*",
44843+
"kind": "core",
44844+
"type": "string",
44845+
"required": false,
44846+
"deprecated": false,
44847+
"sensitive": false,
44848+
"tags": [],
44849+
"hasChildren": false
44850+
},
4477444851
{
4477544852
"path": "models.providers.*.models.*.contextWindow",
4477644853
"kind": "core",

docs/.generated/config-baseline.jsonl

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5554}
1+
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5561}
22
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
33
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
44
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -1921,7 +1921,10 @@
19211921
{"recordType":"path","path":"channels.line.tokenFile","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
19221922
{"recordType":"path","path":"channels.line.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
19231923
{"recordType":"path","path":"channels.matrix","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Matrix","help":"open protocol; install the plugin to enable.","hasChildren":true}
1924-
{"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["access","auth","channels","network","security"],"hasChildren":false}
1924+
{"recordType":"path","path":"channels.matrix.accessToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["access","auth","channels","network","security"],"hasChildren":true}
1925+
{"recordType":"path","path":"channels.matrix.accessToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
1926+
{"recordType":"path","path":"channels.matrix.accessToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
1927+
{"recordType":"path","path":"channels.matrix.accessToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
19251928
{"recordType":"path","path":"channels.matrix.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
19261929
{"recordType":"path","path":"channels.matrix.accounts.*","kind":"channel","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
19271930
{"recordType":"path","path":"channels.matrix.ackReaction","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -2127,6 +2130,7 @@
21272130
{"recordType":"path","path":"channels.msteams.appPassword.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
21282131
{"recordType":"path","path":"channels.msteams.appPassword.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
21292132
{"recordType":"path","path":"channels.msteams.appPassword.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
2133+
{"recordType":"path","path":"channels.msteams.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
21302134
{"recordType":"path","path":"channels.msteams.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
21312135
{"recordType":"path","path":"channels.msteams.blockStreamingCoalesce.idleMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
21322136
{"recordType":"path","path":"channels.msteams.blockStreamingCoalesce.maxChars","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -3462,6 +3466,7 @@
34623466
{"recordType":"path","path":"commands.ownerDisplaySecret","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["access","auth","security"],"label":"Owner ID Hash Secret","help":"Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.","hasChildren":false}
34633467
{"recordType":"path","path":"commands.plugins","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow /plugins","help":"Allow /plugins chat command to list discovered plugins and toggle plugin enablement in config (default: false).","hasChildren":false}
34643468
{"recordType":"path","path":"commands.restart","kind":"core","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Allow Restart","help":"Allow /restart and gateway restart tool actions (default: true).","hasChildren":false}
3469+
{"recordType":"path","path":"commands.shellProfile","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Shell Profile Path","help":"Path to a shell profile file to source before executing commands on the gateway host. When set and non-empty, PowerShell, bash, and other shells will load this profile (e.g., for custom environment variables, aliases, functions). This setting does NOT apply to sandbox execution (host=sandbox, container has isolated filesystem) or node execution (host=node, commands run directly without shell). Leave unset for default behavior without profile loading.","hasChildren":false}
34653470
{"recordType":"path","path":"commands.text","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Text Commands","help":"Enables text-command parsing in chat input in addition to native command surfaces where available. Keep this enabled for compatibility across channels that do not support native command registration.","hasChildren":false}
34663471
{"recordType":"path","path":"commands.useAccessGroups","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Use Access Groups","help":"Enforce access-group allowlists/policies for commands.","hasChildren":false}
34673472
{"recordType":"path","path":"cron","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Cron","help":"Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.","hasChildren":true}
@@ -3950,6 +3955,8 @@
39503955
{"recordType":"path","path":"models.providers.*.models.*.compat.thinkingFormat","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
39513956
{"recordType":"path","path":"models.providers.*.models.*.compat.toolCallArgumentsEncoding","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
39523957
{"recordType":"path","path":"models.providers.*.models.*.compat.toolSchemaProfile","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
3958+
{"recordType":"path","path":"models.providers.*.models.*.compat.unsupportedToolSchemaKeywords","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
3959+
{"recordType":"path","path":"models.providers.*.models.*.compat.unsupportedToolSchemaKeywords.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
39533960
{"recordType":"path","path":"models.providers.*.models.*.contextWindow","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
39543961
{"recordType":"path","path":"models.providers.*.models.*.cost","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
39553962
{"recordType":"path","path":"models.providers.*.models.*.cost.cacheRead","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

src/agents/bash-tools.exec-host-gateway.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type ProcessGatewayAllowlistParams = {
6767
maxOutput: number;
6868
pendingMaxOutput: number;
6969
trustedSafeBinDirs?: ReadonlySet<string>;
70+
shellProfile?: string;
7071
};
7172

7273
export type ProcessGatewayAllowlistResult = {
@@ -284,6 +285,14 @@ export async function processGatewayAllowlist(
284285

285286
recordMatchedAllowlistUse(resolvedPath ?? undefined);
286287

288+
// Security: when an enforced command from allowlist mode is used, ignore shellProfile
289+
// to prevent profile-defined functions/aliases from bypassing allowlist enforcement.
290+
if (enforcedCommand && params.shellProfile) {
291+
params.warnings.push(
292+
"Note: shellProfile ignored in allowlist mode to prevent bypass of enforced command.",
293+
);
294+
}
295+
287296
let run: Awaited<ReturnType<typeof runExecProcess>> | null = null;
288297
try {
289298
run = await runExecProcess({
@@ -302,6 +311,7 @@ export async function processGatewayAllowlist(
302311
scopeKey: params.scopeKey,
303312
sessionKey: params.notifySessionKey,
304313
timeoutSec: effectiveTimeout,
314+
shellProfile: enforcedCommand ? undefined : params.shellProfile,
305315
});
306316
} catch {
307317
await sendExecApprovalFollowupResult(

src/agents/bash-tools.exec-runtime.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
readEnvInt,
3535
} from "./bash-tools.shared.js";
3636
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
37-
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
37+
import { applyProfilePrefix, getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
3838

3939
const SMKX = "\x1b[?1h";
4040
const RMKX = "\x1b[?1l";
@@ -455,6 +455,7 @@ export async function runExecProcess(opts: {
455455
scopeKey?: string;
456456
sessionKey?: string;
457457
timeoutSec: number | null;
458+
shellProfile?: string;
458459
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
459460
}): Promise<ExecProcessHandle> {
460461
const startedAt = Date.now();
@@ -559,6 +560,8 @@ export async function runExecProcess(opts: {
559560
childFallbackArgv: string[];
560561
env: NodeJS.ProcessEnv;
561562
stdinMode: "pipe-open";
563+
shell: string;
564+
shellArgs: string[];
562565
} = await (async () => {
563566
if (opts.sandbox) {
564567
const backendExecSpec = await opts.sandbox.buildExecSpec?.({
@@ -586,15 +589,17 @@ export async function runExecProcess(opts: {
586589
(opts.usePty ? ("pipe-open" as const) : ("pipe-closed" as const)),
587590
};
588591
}
589-
const { shell, args: shellArgs } = getShellConfig();
590-
const childArgv = [shell, ...shellArgs, execCommand];
592+
const { shell, args: shellArgs } = getShellConfig(opts.shellProfile);
593+
const childArgv = [shell, ...applyProfilePrefix(shellArgs, execCommand)];
591594
if (opts.usePty) {
592595
return {
593596
mode: "pty" as const,
594597
ptyCommand: execCommand,
595598
childFallbackArgv: childArgv,
596599
env: shellRuntimeEnv,
597600
stdinMode: "pipe-open" as const,
601+
shell,
602+
shellArgs,
598603
};
599604
}
600605
return {
@@ -642,6 +647,9 @@ export async function runExecProcess(opts: {
642647
...spawnBase,
643648
mode: "pty",
644649
ptyCommand: spawnSpec.ptyCommand,
650+
shellProfile: opts.shellProfile,
651+
shell: spawnSpec.shell,
652+
shellArgs: spawnSpec.shellArgs,
645653
})
646654
: await supervisor.spawn({
647655
...spawnBase,

src/agents/bash-tools.exec-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type ExecToolDefaults = {
2828
notifyOnExit?: boolean;
2929
notifyOnExitEmptySuccess?: boolean;
3030
cwd?: string;
31+
shellProfile?: string;
3132
};
3233

3334
export type ExecElevatedDefaults = {

src/agents/bash-tools.exec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ export function createExecTool(
511511
maxOutput,
512512
pendingMaxOutput,
513513
trustedSafeBinDirs,
514+
shellProfile: defaults?.shellProfile,
514515
});
515516
if (gatewayResult.pendingResult) {
516517
return gatewayResult.pendingResult;
@@ -531,6 +532,15 @@ export function createExecTool(
531532
// before we execute and burn tokens in cron loops.
532533
await validateScriptFileForShellBleed({ command: params.command, workdir });
533534

535+
// Security: when an enforced command from allowlist mode is used, ignore shellProfile
536+
// to prevent profile-defined functions/aliases from bypassing allowlist enforcement.
537+
const effectiveShellProfile = execCommandOverride ? undefined : defaults?.shellProfile;
538+
if (execCommandOverride && defaults?.shellProfile) {
539+
warnings.push(
540+
"Note: shellProfile ignored in allowlist mode to prevent bypass of enforced command.",
541+
);
542+
}
543+
534544
const run = await runExecProcess({
535545
command: params.command,
536546
execCommand: execCommandOverride,
@@ -547,6 +557,7 @@ export function createExecTool(
547557
scopeKey: defaults?.scopeKey,
548558
sessionKey: notifySessionKey,
549559
timeoutSec: effectiveTimeout,
560+
shellProfile: effectiveShellProfile,
550561
onUpdate,
551562
});
552563

src/agents/pi-tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
158158
notifyOnExitEmptySuccess:
159159
agentExec?.notifyOnExitEmptySuccess ?? globalExec?.notifyOnExitEmptySuccess,
160160
applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch,
161+
shellProfile: cfg?.commands?.shellProfile,
161162
};
162163
}
163164

@@ -444,6 +445,7 @@ export function createOpenClawCodingTools(options?: {
444445
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
445446
notifyOnExitEmptySuccess:
446447
options?.exec?.notifyOnExitEmptySuccess ?? execConfig.notifyOnExitEmptySuccess,
448+
shellProfile: options?.exec?.shellProfile ?? execConfig.shellProfile,
447449
sandbox: sandbox
448450
? {
449451
containerName: sandbox.containerName,

0 commit comments

Comments
 (0)