diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ea951ec9f..8bcb99622 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -746,8 +746,10 @@ Controls how chat commands are enabled across connectors. ```json5 { commands: { - native: false, // register native commands when supported + native: "auto", // register native commands when supported (auto) text: true, // parse slash commands in chat messages + bash: false, // allow ! (alias: /bash) (host-only; requires tools.elevated allowlists) + bashForegroundMs: 2000, // bash foreground window (0 backgrounds immediately) config: false, // allow /config (writes to disk) debug: false, // allow /debug (runtime-only overrides) restart: false, // allow /restart + gateway restart tool @@ -761,6 +763,8 @@ Notes: - `commands.text: false` disables parsing chat messages for commands. - `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported providers stay text-only. - Set `commands.native: true|false` to force all, or override per provider with `discord.commands.native`, `telegram.commands.native`, `slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app. +- `commands.bash: true` enables `! ` to run host shell commands (`/bash ` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.`. +- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! ` requests are rejected (one at a time). - `commands.config: true` enables `/config` (reads/writes `clawdbot.json`). - `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.restart: true` enables `/restart` and the gateway tool restart action. diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index d42ea76c5..85550d23c 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -7,6 +7,7 @@ read_when: ## What it does - Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved. +- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host. - **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op. - Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - Only `on|off` are accepted; anything else returns a hint and does not change state. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 5ae70c363..ff8d3a5e9 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -7,6 +7,7 @@ read_when: # Slash commands Commands are handled by the Gateway. Most commands must be sent as a **standalone** message that starts with `/`. +The host-only bash chat command uses `! ` (with `/bash ` as an alias). There are two related systems: @@ -26,6 +27,8 @@ They run immediately, are stripped before the model sees the message, and the re commands: { native: "auto", text: true, + bash: false, + bashForegroundMs: 2000, config: false, debug: false, restart: false, @@ -40,6 +43,8 @@ They run immediately, are stripped before the model sees the message, and the re - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. - Set `discord.commands.native`, `telegram.commands.native`, or `slack.commands.native` to override per provider (bool or `"auto"`). - `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. +- `commands.bash` (default `false`) enables `! ` to run host shell commands (`/bash ` is an alias; requires `tools.elevated` allowlists). +- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately). - `commands.config` (default `false`) enables `/config` (reads/writes `clawdbot.json`). - `commands.debug` (default `false`) enables `/debug` (runtime-only overrides). - `commands.useAccessGroups` (default `true`) enforces allowlists/policies for commands. @@ -66,9 +71,13 @@ Text + native (when enabled): - `/elevated on|off` (alias: `/elev`) - `/model ` (alias: `/models`; or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) +- `/bash ` (host-only; alias for `! `; requires `commands.bash: true` + `tools.elevated` allowlists) Text-only: - `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction)) +- `! ` (host-only; one at a time; use `!poll` + `!stop` for long-running jobs) +- `!poll` (check output / status; accepts optional `sessionId`; `/bash poll` also works) +- `!stop` (stop the running bash job; accepts optional `sessionId`; `/bash stop` also works) Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index e0470d1fc..f5216f597 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -251,6 +251,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { textAlias: "/queue", acceptsArgs: true, }), + defineChatCommand({ + key: "bash", + description: "Run host shell commands (host-only).", + textAlias: "/bash", + scope: "text", + acceptsArgs: true, + }), ]; registerAlias(commands, "status", "/usage"); @@ -314,6 +321,7 @@ export function isCommandEnabled( ): boolean { if (commandKey === "config") return cfg.commands?.config === true; if (commandKey === "debug") return cfg.commands?.debug === true; + if (commandKey === "bash") return cfg.commands?.bash === true; return true; } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 94403c00d..3b3bd65a5 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -981,6 +981,11 @@ export async function getReplyFromConfig( command: inlineCommandContext, agentId, directives, + elevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + failures: elevatedFailures, + }, sessionEntry, sessionStore, sessionKey, @@ -1038,6 +1043,11 @@ export async function getReplyFromConfig( command, agentId, directives, + elevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + failures: elevatedFailures, + }, sessionEntry, sessionStore, sessionKey, diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts new file mode 100644 index 000000000..3ea545199 --- /dev/null +++ b/src/auto-reply/reply/bash-command.ts @@ -0,0 +1,416 @@ +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { + getFinishedSession, + getSession, + markExited, +} from "../../agents/bash-process-registry.js"; +import { createExecTool } from "../../agents/bash-tools.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import { killProcessTree } from "../../agents/shell-utils.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import type { MsgContext } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; +import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; + +const CHAT_BASH_SCOPE_KEY = "chat:bash"; +const DEFAULT_FOREGROUND_MS = 2000; +const MAX_FOREGROUND_MS = 30_000; + +type BashRequest = + | { action: "help" } + | { action: "run"; command: string } + | { action: "poll"; sessionId?: string } + | { action: "stop"; sessionId?: string }; + +type ActiveBashJob = + | { state: "starting"; startedAt: number; command: string } + | { + state: "running"; + sessionId: string; + startedAt: number; + command: string; + watcherAttached: boolean; + }; + +let activeJob: ActiveBashJob | null = null; + +function clampNumber(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function resolveForegroundMs(cfg: ClawdbotConfig): number { + const raw = cfg.commands?.bashForegroundMs; + if (typeof raw !== "number" || Number.isNaN(raw)) + return DEFAULT_FOREGROUND_MS; + return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS); +} + +function formatSessionSnippet(sessionId: string) { + const trimmed = sessionId.trim(); + if (trimmed.length <= 12) return trimmed; + return `${trimmed.slice(0, 8)}…`; +} + +function formatOutputBlock(text: string) { + const trimmed = text.trim(); + if (!trimmed) return "(no output)"; + return `\`\`\`txt\n${trimmed}\n\`\`\``; +} + +function parseBashRequest(raw: string): BashRequest | null { + const trimmed = raw.trimStart(); + let restSource = ""; + if (trimmed.toLowerCase().startsWith("/bash")) { + const match = trimmed.match(/^\/bash(?:\s*:\s*|\s+|$)([\s\S]*)$/i); + if (!match) return null; + restSource = match[1] ?? ""; + } else if (trimmed.startsWith("!")) { + restSource = trimmed.slice(1); + if (restSource.trimStart().startsWith(":")) { + restSource = restSource.trimStart().slice(1); + } + } else { + return null; + } + + const rest = restSource.trimStart(); + if (!rest) return { action: "help" }; + const tokenMatch = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); + const token = tokenMatch?.[1]?.trim() ?? ""; + const remainder = tokenMatch?.[2]?.trim() ?? ""; + const lowered = token.toLowerCase(); + if (lowered === "poll") { + return { action: "poll", sessionId: remainder || undefined }; + } + if (lowered === "stop") { + return { action: "stop", sessionId: remainder || undefined }; + } + if (lowered === "help") { + return { action: "help" }; + } + return { action: "run", command: rest }; +} + +function resolveRawCommandBody(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId?: string; + isGroup: boolean; +}) { + const source = + params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? ""; + const stripped = stripStructuralPrefixes(source); + return params.isGroup + ? stripMentions(stripped, params.ctx, params.cfg, params.agentId) + : stripped; +} + +function getScopedSession(sessionId: string) { + const running = getSession(sessionId); + if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) return { running }; + const finished = getFinishedSession(sessionId); + if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) + return { finished }; + return {}; +} + +function ensureActiveJobState() { + if (!activeJob) return null; + if (activeJob.state === "starting") return activeJob; + const { running, finished } = getScopedSession(activeJob.sessionId); + if (running) return activeJob; + if (finished) { + activeJob = null; + return null; + } + activeJob = null; + return null; +} + +function attachActiveWatcher(sessionId: string) { + if (!activeJob || activeJob.state !== "running") return; + if (activeJob.sessionId !== sessionId) return; + if (activeJob.watcherAttached) return; + const { running } = getScopedSession(sessionId); + const child = running?.child; + if (!child) return; + activeJob.watcherAttached = true; + child.once("close", () => { + if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { + activeJob = null; + } + }); +} + +function buildUsageReply(): ReplyPayload { + return { + text: [ + "⚙️ Usage:", + "- ! ", + "- !poll | ! poll", + "- !stop | ! stop", + "- /bash ... (alias; same subcommands as !)", + ].join("\n"), + }; +} + +function formatElevatedUnavailableMessage(params: { + runtimeSandboxed: boolean; + failures: Array<{ gate: string; key: string }>; + sessionKey?: string; +}): string { + const lines: string[] = []; + lines.push( + `elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`, + ); + if (params.failures.length > 0) { + lines.push( + `Failing gates: ${params.failures + .map((f) => `${f.gate} (${f.key})`) + .join(", ")}`, + ); + } else { + lines.push( + "Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.).", + ); + } + lines.push("Fix-it keys:"); + lines.push("- tools.elevated.enabled"); + lines.push("- tools.elevated.allowFrom."); + lines.push("- agents.list[].tools.elevated.enabled"); + lines.push("- agents.list[].tools.elevated.allowFrom."); + if (params.sessionKey) { + lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`); + } + return lines.join("\n"); +} + +export async function handleBashChatCommand(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + agentId?: string; + sessionKey: string; + isGroup: boolean; + elevated: { + enabled: boolean; + allowed: boolean; + failures: Array<{ gate: string; key: string }>; + }; +}): Promise { + if (params.cfg.commands?.bash !== true) { + return { + text: "⚠️ bash is disabled. Set commands.bash=true to enable.", + }; + } + + const agentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + + if (!params.elevated.enabled || !params.elevated.allowed) { + const runtimeSandboxed = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: params.ctx.SessionKey, + }).sandboxed; + return { + text: formatElevatedUnavailableMessage({ + runtimeSandboxed, + failures: params.elevated.failures, + sessionKey: params.ctx.SessionKey, + }), + }; + } + + const rawBody = resolveRawCommandBody({ + ctx: params.ctx, + cfg: params.cfg, + agentId, + isGroup: params.isGroup, + }).trim(); + const request = parseBashRequest(rawBody); + if (!request) { + return { text: "⚠️ Unrecognized bash request." }; + } + + const liveJob = ensureActiveJobState(); + + if (request.action === "help") { + return buildUsageReply(); + } + + if (request.action === "poll") { + const sessionId = + request.sessionId?.trim() || + (liveJob?.state === "running" ? liveJob.sessionId : ""); + if (!sessionId) { + return { text: "⚙️ No active bash job." }; + } + const { running, finished } = getScopedSession(sessionId); + if (running) { + attachActiveWatcher(sessionId); + const runtimeSec = Math.max( + 0, + Math.floor((Date.now() - running.startedAt) / 1000), + ); + const tail = running.tail || "(no output yet)"; + return { + text: [ + `⚙️ bash still running (session ${formatSessionSnippet(sessionId)}, ${runtimeSec}s).`, + formatOutputBlock(tail), + "Hint: !stop (or /bash stop)", + ].join("\n"), + }; + } + if (finished) { + if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { + activeJob = null; + } + const exitLabel = finished.exitSignal + ? `signal ${String(finished.exitSignal)}` + : `code ${String(finished.exitCode ?? 0)}`; + const prefix = finished.status === "completed" ? "⚙️" : "⚠️"; + return { + text: [ + `${prefix} bash finished (session ${formatSessionSnippet(sessionId)}).`, + `Exit: ${exitLabel}`, + formatOutputBlock(finished.aggregated || finished.tail), + ].join("\n"), + }; + } + if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { + activeJob = null; + } + return { + text: `⚙️ No bash session found for ${formatSessionSnippet(sessionId)}.`, + }; + } + + if (request.action === "stop") { + const sessionId = + request.sessionId?.trim() || + (liveJob?.state === "running" ? liveJob.sessionId : ""); + if (!sessionId) { + return { text: "⚙️ No active bash job." }; + } + const { running } = getScopedSession(sessionId); + if (!running) { + if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { + activeJob = null; + } + return { + text: `⚙️ No running bash job found for ${formatSessionSnippet(sessionId)}.`, + }; + } + if (!running.backgrounded) { + return { + text: `⚠️ Session ${formatSessionSnippet(sessionId)} is not backgrounded.`, + }; + } + const pid = running.pid ?? running.child?.pid; + if (pid) { + killProcessTree(pid); + } + markExited(running, null, "SIGKILL", "failed"); + if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { + activeJob = null; + } + return { + text: `⚙️ bash stopped (session ${formatSessionSnippet(sessionId)}).`, + }; + } + + // request.action === "run" + if (liveJob) { + const label = + liveJob.state === "running" + ? formatSessionSnippet(liveJob.sessionId) + : "starting"; + return { + text: `⚠️ A bash job is already running (${label}). Use !poll / !stop (or /bash poll / /bash stop).`, + }; + } + + const commandText = request.command.trim(); + if (!commandText) return buildUsageReply(); + + activeJob = { + state: "starting", + startedAt: Date.now(), + command: commandText, + }; + + try { + const foregroundMs = resolveForegroundMs(params.cfg); + const shouldBackgroundImmediately = foregroundMs <= 0; + const timeoutSec = + params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec; + const execTool = createExecTool({ + scopeKey: CHAT_BASH_SCOPE_KEY, + allowBackground: true, + timeoutSec, + elevated: { + enabled: params.elevated.enabled, + allowed: params.elevated.allowed, + defaultLevel: "on", + }, + }); + const result = await execTool.execute("chat-bash", { + command: commandText, + background: shouldBackgroundImmediately, + yieldMs: shouldBackgroundImmediately ? undefined : foregroundMs, + timeout: timeoutSec, + elevated: true, + }); + + if (result.details?.status === "running") { + const sessionId = result.details.sessionId; + activeJob = { + state: "running", + sessionId, + startedAt: result.details.startedAt, + command: commandText, + watcherAttached: false, + }; + attachActiveWatcher(sessionId); + const snippet = formatSessionSnippet(sessionId); + logVerbose(`Started bash session ${snippet}: ${commandText}`); + return { + text: `⚙️ bash started (session ${sessionId}). Still running; use !poll / !stop (or /bash poll / /bash stop).`, + }; + } + + // Completed in foreground. + activeJob = null; + const exitCode = + result.details?.status === "completed" ? result.details.exitCode : 0; + const output = + result.details?.status === "completed" + ? result.details.aggregated + : result.content + .map((chunk) => (chunk.type === "text" ? chunk.text : "")) + .join("\n"); + return { + text: [ + `⚙️ bash: ${commandText}`, + `Exit: ${exitCode}`, + formatOutputBlock(output || "(no output)"), + ].join("\n"), + }; + } catch (err) { + activeJob = null; + const message = err instanceof Error ? err.message : String(err); + return { + text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join( + "\n", + ), + }; + } +} + +export function resetBashChatCommandForTests() { + activeJob = null; +} diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 624ebe829..c195dbb31 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; +import { resetBashChatCommandForTests } from "./bash-command.js"; import { buildCommandContext, handleCommands } from "./commands.js"; import { parseInlineDirectives } from "./directive-handling.js"; @@ -33,6 +34,7 @@ function buildParams( cfg, command, directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, sessionKey: "agent:main:main", workspaceDir: "/tmp", defaultGroupActivation: () => "mention", @@ -47,6 +49,37 @@ function buildParams( } describe("handleCommands gating", () => { + it("blocks /bash when disabled", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: false, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("/bash echo hi", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("bash is disabled"); + }); + + it("blocks /bash when elevated is not allowlisted", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: true, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("/bash echo hi", cfg); + params.elevated = { + enabled: true, + allowed: false, + failures: [ + { gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" }, + ], + }; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("elevated is not available"); + }); + it("blocks /config when disabled", async () => { const cfg = { commands: { config: false, debug: false, text: true }, @@ -70,6 +103,32 @@ describe("handleCommands gating", () => { }); }); +describe("handleCommands bash alias", () => { + it("routes !poll through the /bash handler", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: true, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("!poll", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("No active bash job"); + }); + + it("routes !stop through the /bash handler", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: true, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("!stop", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("No active bash job"); + }); +}); + describe("handleCommands identity", () => { it("returns sender details for /whoami", async () => { const cfg = { diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index fcb7b7e54..991440059 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -83,6 +83,7 @@ import type { } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; +import { handleBashChatCommand } from "./bash-command.js"; import { parseConfigCommand } from "./config-commands.js"; import { parseDebugCommand } from "./debug-commands.js"; import type { InlineDirectives } from "./directive-handling.js"; @@ -400,6 +401,11 @@ export async function handleCommands(params: { command: CommandContext; agentId?: string; directives: InlineDirectives; + elevated: { + enabled: boolean; + allowed: boolean; + failures: Array<{ gate: string; key: string }>; + }; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey: string; @@ -425,6 +431,7 @@ export async function handleCommands(params: { cfg, command, directives, + elevated, sessionEntry, sessionStore, sessionKey, @@ -465,6 +472,30 @@ export async function handleCommands(params: { commandSource: ctx.CommandSource, }); + const bashSlashRequested = + allowTextCommands && + (command.commandBodyNormalized === "/bash" || + command.commandBodyNormalized.startsWith("/bash ")); + const bashBangRequested = + allowTextCommands && command.commandBodyNormalized.startsWith("!"); + if (bashSlashRequested || (bashBangRequested && command.isAuthorizedSender)) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /bash from unauthorized sender: ${command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const reply = await handleBashChatCommand({ + ctx, + cfg, + agentId: params.agentId, + sessionKey, + isGroup, + elevated, + }); + return { shouldContinue: false, reply }; + } + if (allowTextCommands && activationCommand.hasCommand) { if (!isGroup) { return { diff --git a/src/config/schema.ts b/src/config/schema.ts index 4d5f0045e..5f929ea23 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -155,6 +155,8 @@ const FIELD_LABELS: Record = { "agents.defaults.cliBackends": "CLI Backends", "commands.native": "Native Commands", "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", "commands.config": "Allow /config", "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", @@ -289,6 +291,10 @@ const FIELD_HELP: Record = { "commands.native": "Register native commands with connectors that support it (Discord/Slack/Telegram).", "commands.text": "Allow text command parsing (slash commands only).", + "commands.bash": + "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", + "commands.bashForegroundMs": + "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "commands.config": "Allow /config chat command to read/write config on disk (default: false).", "commands.debug": diff --git a/src/config/types.ts b/src/config/types.ts index ee5c261b8..7f8b12ce9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1244,6 +1244,10 @@ export type CommandsConfig = { native?: NativeCommandsSetting; /** Enable text command parsing (default: true). */ text?: boolean; + /** Allow bash chat command (`!`; `/bash` alias) (default: false). */ + bash?: boolean; + /** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */ + bashForegroundMs?: number; /** Allow /config command (default: false). */ config?: boolean; /** Allow /debug command (default: false). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 9287e49e7..c88fa93dc 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -724,6 +724,8 @@ const CommandsSchema = z .object({ native: NativeCommandsSettingSchema.optional().default("auto"), text: z.boolean().optional(), + bash: z.boolean().optional(), + bashForegroundMs: z.number().int().min(0).max(30_000).optional(), config: z.boolean().optional(), debug: z.boolean().optional(), restart: z.boolean().optional(),