From fe0b3500cca9520b3384b3590ac817c8d4e5eb29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 05:15:42 +0000 Subject: [PATCH] feat: add elevated bash mode --- docs/background-process.md | 1 + docs/bash.md | 1 + docs/configuration.md | 26 ++++ docs/faq.md | 1 + docs/thinking.md | 5 + docs/tools.md | 1 + docs/tui.md | 1 + src/agents/bash-tools.test.ts | 13 ++ src/agents/bash-tools.ts | 33 ++++- src/agents/pi-embedded-runner.ts | 7 +- src/auto-reply/reply.directive.test.ts | 7 ++ src/auto-reply/reply.triggers.test.ts | 79 ++++++++++++ src/auto-reply/reply.ts | 138 ++++++++++++++++++++- src/auto-reply/reply/agent-runner.ts | 1 + src/auto-reply/reply/directive-handling.ts | 60 ++++++++- src/auto-reply/reply/directives.ts | 26 +++- src/auto-reply/reply/followup-runner.ts | 1 + src/auto-reply/reply/queue.ts | 8 +- src/auto-reply/status.ts | 4 +- src/auto-reply/thinking.ts | 12 ++ src/commands/status.ts | 5 + src/config/config.ts | 34 +++++ src/config/sessions.ts | 1 + src/gateway/protocol/schema.ts | 1 + src/gateway/server-bridge.ts | 20 +++ src/gateway/session-utils.ts | 2 + src/tui/commands.ts | 10 ++ src/tui/tui.ts | 16 +++ ui/src/ui/types.ts | 2 + 29 files changed, 509 insertions(+), 7 deletions(-) diff --git a/docs/background-process.md b/docs/background-process.md index d39d6e26e..3ab825a91 100644 --- a/docs/background-process.md +++ b/docs/background-process.md @@ -16,6 +16,7 @@ Key parameters: - `yieldMs` (default 10000): auto‑background after this delay - `background` (bool): background immediately - `timeout` (seconds, default 1800): kill the process after this timeout +- `elevated` (bool): run on host if elevated mode is enabled/allowed - Need a real TTY? Use the tmux skill. - `workdir`, `env` diff --git a/docs/bash.md b/docs/bash.md index d65cdeeee..75211c2d9 100644 --- a/docs/bash.md +++ b/docs/bash.md @@ -15,6 +15,7 @@ Run shell commands in the workspace. Supports foreground + background execution - `yieldMs` (default 10000): auto-background after delay - `background` (bool): background immediately - `timeout` (seconds, default 1800): kill on expiry +- `elevated` (bool): run on host if elevated mode is enabled/allowed - Need a real TTY? Use the tmux skill. ## Examples diff --git a/docs/configuration.md b/docs/configuration.md index b8440973e..2107bb323 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -388,6 +388,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts). }, thinkingDefault: "low", verboseDefault: "off", + elevatedDefault: "off", timeoutSeconds: 600, mediaMaxMb: 5, heartbeat: { @@ -439,6 +440,31 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require - `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) +`agent.elevated` controls elevated (host) bash access: +- `enabled`: allow elevated mode (default true) +- `allowFrom`: per-surface allowlists (required to enable; empty = disabled) + - `whatsapp`: E.164 numbers + - `telegram`: chat ids or usernames + - `discord`: user ids or usernames + - `signal`: E.164 numbers + - `imessage`: handles/chat ids + - `webchat`: session ids or usernames + +Example: +```json5 +{ + agent: { + elevated: { + enabled: true, + allowFrom: { + whatsapp: ["+15555550123"], + discord: ["steipete", "1234567890123"] + } + } + } +} +``` + `agent.maxConcurrent` sets the maximum number of embedded agent runs that can execute in parallel across sessions. Each session is still serialized (one run per session key at a time). Default: 1. diff --git a/docs/faq.md b/docs/faq.md index e3b3f1651..46e0d0285 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -494,6 +494,7 @@ Quick reference (send these in chat): | `/new` or `/reset` | Reset the session | | `/think ` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/verbose on\|off` | Toggle verbose mode | +| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) | | `/activation mention\|always` | Group activation (owner-only) | | `/model ` | Switch AI model (see below) | | `/queue instant\|batch\|serial` | Message queuing mode | diff --git a/docs/thinking.md b/docs/thinking.md index 0a2a196fe..012387401 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -34,6 +34,11 @@ read_when: - Inline directive affects only that message; session/global defaults apply otherwise. - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with ` : ` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. +## Elevated directives (/elevated or /elev) +- Levels: `on` or `off` (default). +- Directive-only message toggles session elevated mode and replies `Elevated mode enabled.` / `Elevated mode disabled.`. +- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies `elevated is not available right now.` and does not change session state. + ## Heartbeats - Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). diff --git a/docs/tools.md b/docs/tools.md index c5f3522dd..5a6ca194a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -21,6 +21,7 @@ Core parameters: - `yieldMs` (auto-background after timeout, default 10000) - `background` (immediate background) - `timeout` (seconds; kills the process if exceeded, default 1800) +- `elevated` (bool; run on host if elevated mode is enabled/allowed) - Need a real TTY? Use the tmux skill. Notes: diff --git a/docs/tui.md b/docs/tui.md index f70999dd5..4698235d8 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -51,6 +51,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/model ` (or `/models`) - `/think ` - `/verbose ` +- `/elevated ` - `/activation ` - `/deliver ` - `/new` or `/reset` diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index 5872f5d2e..defeaa5bd 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -119,6 +119,19 @@ describe("bash tool backgrounding", () => { expect(status).toBe("failed"); }); + it("rejects elevated requests when not allowed", async () => { + const customBash = createBashTool({ + elevated: { enabled: true, allowed: false, defaultLevel: "off" }, + }); + + await expect( + customBash.execute("call1", { + command: "echo hi", + elevated: true, + }), + ).rejects.toThrow("elevated is not available right now."); + }); + it("logs line-based slices and defaults to last lines", async () => { const result = await bashTool.execute("call1", { command: diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 5d7cc3f34..71d98495d 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -26,6 +26,7 @@ import { killProcessTree, sanitizeBinaryOutput, } from "./shell-utils.js"; +import { logInfo } from "../logger.js"; const CHUNK_LIMIT = 8 * 1024; const DEFAULT_MAX_OUTPUT = clampNumber( @@ -53,6 +54,7 @@ export type BashToolDefaults = { backgroundMs?: number; timeoutSec?: number; sandbox?: BashSandboxConfig; + elevated?: BashElevatedDefaults; }; export type ProcessToolDefaults = { @@ -66,6 +68,12 @@ export type BashSandboxConfig = { env?: Record; }; +export type BashElevatedDefaults = { + enabled: boolean; + allowed: boolean; + defaultLevel: "on" | "off"; +}; + const bashSchema = Type.Object({ command: Type.String({ description: "Bash command to execute" }), workdir: Type.Optional( @@ -85,6 +93,11 @@ const bashSchema = Type.Object({ description: "Timeout in seconds (optional, kills process on expiry)", }), ), + elevated: Type.Optional( + Type.Boolean({ + description: "Run on the host with elevated permissions (if allowed)", + }), + ), }); export type BashToolDetails = @@ -131,6 +144,7 @@ export function createBashTool( yieldMs?: number; background?: boolean; timeout?: number; + elevated?: boolean; }; if (!params.command) { @@ -149,7 +163,24 @@ export function createBashTool( const startedAt = Date.now(); const sessionId = randomUUID(); const warnings: string[] = []; - const sandbox = defaults?.sandbox; + const elevatedDefaults = defaults?.elevated; + const elevatedRequested = + typeof params.elevated === "boolean" + ? params.elevated + : elevatedDefaults?.defaultLevel === "on"; + if (elevatedRequested) { + if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { + throw new Error("elevated is not available right now."); + } + logInfo( + `bash: elevated command (${sessionId.slice(0, 8)}) ${truncateMiddle( + params.command, + 120, + )}`, + ); + } + + const sandbox = elevatedRequested ? undefined : defaults?.sandbox; const rawWorkdir = params.workdir?.trim() || process.cwd(); let workdir = rawWorkdir; let containerWorkdir = sandbox?.containerWorkdir; diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 7babec643..7341a9850 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -34,6 +34,7 @@ import { } from "../process/command-queue.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { resolveClawdisAgentDir } from "./agent-paths.js"; +import type { BashElevatedDefaults } from "./bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { ensureClawdisModelsJson } from "./models-config.js"; import { @@ -390,6 +391,7 @@ export async function runEmbeddedPiAgent(params: { model?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; + bashElevated?: BashElevatedDefaults; timeoutMs: number; runId: string; abortSignal?: AbortSignal; @@ -495,7 +497,10 @@ export async function runEmbeddedPiAgent(params: { const contextFiles = buildBootstrapContextFiles(bootstrapFiles); const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); const tools = createClawdisCodingTools({ - bash: params.config?.agent?.bash, + bash: { + ...params.config?.agent?.bash, + elevated: params.bashElevated, + }, sandbox, surface: params.surface, sessionKey: params.sessionKey ?? params.sessionId, diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 1a2130503..d6ca3752f 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -13,6 +13,7 @@ import { } from "../config/sessions.js"; import { drainSystemEvents } from "../infra/system-events.js"; import { + extractElevatedDirective, extractQueueDirective, extractReplyToTag, extractThinkDirective, @@ -85,6 +86,12 @@ describe("directive parsing", () => { expect(res.verboseLevel).toBe("on"); }); + it("matches elevated with leading space", () => { + const res = extractElevatedDirective(" please /elevated on now"); + expect(res.hasDirective).toBe(true); + expect(res.elevatedLevel).toBe("on"); + }); + it("matches think at start of line", () => { const res = extractThinkDirective("/think:high run slow"); expect(res.hasDirective).toBe(true); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 31e2f103f..3e09eac26 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -143,6 +143,85 @@ describe("trigger handling", () => { }); }); + it("allows approved sender to toggle elevated mode", async () => { + await withTempHome(async (home) => { + const cfg = { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1000"] }, + }, + }, + whatsapp: { + allowFrom: ["+1000"], + }, + session: { store: join(home, "sessions.json") }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1000", + To: "+2000", + Surface: "whatsapp", + SenderE164: "+1000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode enabled"); + + const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); + const store = JSON.parse(storeRaw) as Record< + string, + { elevatedLevel?: string } + >; + expect(store.main?.elevatedLevel).toBe("on"); + }); + }); + + it("rejects elevated toggles when disabled", async () => { + await withTempHome(async (home) => { + const cfg = { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + elevated: { + enabled: false, + allowFrom: { whatsapp: ["+1000"] }, + }, + }, + whatsapp: { + allowFrom: ["+1000"], + }, + session: { store: join(home, "sessions.json") }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated on", + From: "+1000", + To: "+2000", + Surface: "whatsapp", + SenderE164: "+1000", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("elevated is not available right now."); + + const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); + const store = JSON.parse(storeRaw) as Record< + string, + { elevatedLevel?: string } + >; + expect(store.main?.elevatedLevel).toBeUndefined(); + }); + }); + it("returns a context overflow fallback when the embedded agent throws", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockRejectedValue( diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 4ef5412aa..33f26f90b 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -11,7 +11,11 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { type ClawdisConfig, loadConfig } from "../config/config.js"; +import { + type AgentElevatedAllowFromConfig, + type ClawdisConfig, + loadConfig, +} from "../config/config.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { logVerbose } from "../globals.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; @@ -47,6 +51,7 @@ import { createTypingController } from "./reply/typing.js"; import type { MsgContext } from "./templating.js"; import { normalizeThinkLevel, + type ElevatedLevel, type ThinkLevel, type VerboseLevel, } from "./thinking.js"; @@ -55,6 +60,7 @@ import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; export { + extractElevatedDirective, extractThinkDirective, extractVerboseDirective, } from "./reply/directives.js"; @@ -65,6 +71,99 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js"; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; +function normalizeAllowToken(value?: string) { + if (!value) return ""; + return value.trim().toLowerCase(); +} + +function slugAllowToken(value?: string) { + if (!value) return ""; + let text = value.trim().toLowerCase(); + if (!text) return ""; + text = text.replace(/^[@#]+/, ""); + text = text.replace(/[\s_]+/g, "-"); + text = text.replace(/[^a-z0-9-]+/g, "-"); + return text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); +} + +function stripSenderPrefix(value?: string) { + if (!value) return ""; + const trimmed = value.trim(); + return trimmed.replace( + /^(whatsapp|telegram|discord|signal|imessage|webchat|user|group|channel):/i, + "", + ); +} + +function resolveElevatedAllowList( + allowFrom: AgentElevatedAllowFromConfig | undefined, + surface: string, +): Array | undefined { + switch (surface) { + case "whatsapp": + return allowFrom?.whatsapp; + case "telegram": + return allowFrom?.telegram; + case "discord": + return allowFrom?.discord; + case "signal": + return allowFrom?.signal; + case "imessage": + return allowFrom?.imessage; + case "webchat": + return allowFrom?.webchat; + default: + return undefined; + } +} + +function isApprovedElevatedSender(params: { + surface: string; + ctx: MsgContext; + allowFrom?: AgentElevatedAllowFromConfig; +}): boolean { + const rawAllow = resolveElevatedAllowList(params.allowFrom, params.surface); + if (!rawAllow || rawAllow.length === 0) return false; + + const allowTokens = rawAllow + .map((entry) => String(entry).trim()) + .filter(Boolean); + if (allowTokens.length === 0) return false; + if (allowTokens.some((entry) => entry === "*")) return true; + + const tokens = new Set(); + const addToken = (value?: string) => { + if (!value) return; + const trimmed = value.trim(); + if (!trimmed) return; + tokens.add(trimmed); + const normalized = normalizeAllowToken(trimmed); + if (normalized) tokens.add(normalized); + const slugged = slugAllowToken(trimmed); + if (slugged) tokens.add(slugged); + }; + + addToken(params.ctx.SenderName); + addToken(params.ctx.SenderE164); + addToken(params.ctx.From); + addToken(stripSenderPrefix(params.ctx.From)); + addToken(params.ctx.To); + addToken(stripSenderPrefix(params.ctx.To)); + + for (const rawEntry of allowTokens) { + const entry = rawEntry.trim(); + if (!entry) continue; + const stripped = stripSenderPrefix(entry); + if (tokens.has(entry) || tokens.has(stripped)) return true; + const normalized = normalizeAllowToken(stripped); + if (normalized && tokens.has(normalized)) return true; + const slugged = slugAllowToken(stripped); + if (slugged && tokens.has(slugged)) return true; + } + + return false; +} + export async function getReplyFromConfig( ctx: MsgContext, opts?: GetReplyOptions, @@ -146,6 +245,27 @@ export async function getReplyFromConfig( sessionCtx.Body = directives.cleaned; sessionCtx.BodyStripped = directives.cleaned; + const surfaceKey = + sessionCtx.Surface?.trim().toLowerCase() ?? + ctx.Surface?.trim().toLowerCase() ?? + ""; + const elevatedConfig = agentCfg?.elevated; + const elevatedEnabled = elevatedConfig?.enabled !== false; + const elevatedAllowed = + elevatedEnabled && + Boolean( + surfaceKey && + isApprovedElevatedSender({ + surface: surfaceKey, + ctx, + allowFrom: elevatedConfig?.allowFrom, + }), + ); + if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) { + typing.cleanup(); + return { text: "elevated is not available right now." }; + } + const requireMention = resolveGroupRequireMention({ cfg, ctx: sessionCtx, @@ -161,6 +281,12 @@ export async function getReplyFromConfig( (directives.verboseLevel as VerboseLevel | undefined) ?? (sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (agentCfg?.verboseDefault as VerboseLevel | undefined); + const resolvedElevatedLevel = elevatedAllowed + ? ((directives.elevatedLevel as ElevatedLevel | undefined) ?? + (sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ?? + (agentCfg?.elevatedDefault as ElevatedLevel | undefined) ?? + "off") + : "off"; const resolvedBlockStreaming = agentCfg?.blockStreamingDefault === "off" ? "off" : "on"; const resolvedBlockStreamingBreak = @@ -220,6 +346,8 @@ export async function getReplyFromConfig( sessionStore, sessionKey, storePath, + elevatedEnabled, + elevatedAllowed, defaultProvider, defaultModel, aliasIndex, @@ -242,6 +370,8 @@ export async function getReplyFromConfig( sessionStore, sessionKey, storePath, + elevatedEnabled, + elevatedAllowed, defaultProvider, defaultModel, aliasIndex, @@ -466,6 +596,12 @@ export async function getReplyFromConfig( model, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, + elevatedLevel: resolvedElevatedLevel, + bashElevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + defaultLevel: resolvedElevatedLevel ?? "off", + }, timeoutMs, blockReplyBreak: resolvedBlockStreamingBreak, ownerNumbers: diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 52d9f191f..ac0535308 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -187,6 +187,7 @@ export async function runReplyAgent(params: { model: followupRun.run.model, thinkLevel: followupRun.run.thinkLevel, verboseLevel: followupRun.run.verboseLevel, + bashElevated: followupRun.run.bashElevated, timeoutMs: followupRun.run.timeoutMs, runId, blockReplyBreak: resolvedBlockStreamingBreak, diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index a8e03d7e9..b6fbb1c24 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -18,8 +18,10 @@ import { extractModelDirective } from "../model.js"; import type { MsgContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { + extractElevatedDirective, extractThinkDirective, extractVerboseDirective, + type ElevatedLevel, type ThinkLevel, type VerboseLevel, } from "./directives.js"; @@ -44,6 +46,9 @@ export type InlineDirectives = { hasVerboseDirective: boolean; verboseLevel?: VerboseLevel; rawVerboseLevel?: string; + hasElevatedDirective: boolean; + elevatedLevel?: ElevatedLevel; + rawElevatedLevel?: string; hasModelDirective: boolean; rawModelDirective?: string; hasQueueDirective: boolean; @@ -72,11 +77,17 @@ export function parseInlineDirectives(body: string): InlineDirectives { rawLevel: rawVerboseLevel, hasDirective: hasVerboseDirective, } = extractVerboseDirective(thinkCleaned); + const { + cleaned: elevatedCleaned, + elevatedLevel, + rawLevel: rawElevatedLevel, + hasDirective: hasElevatedDirective, + } = extractElevatedDirective(verboseCleaned); const { cleaned: modelCleaned, rawModel, hasDirective: hasModelDirective, - } = extractModelDirective(verboseCleaned); + } = extractModelDirective(elevatedCleaned); const { cleaned: queueCleaned, queueMode, @@ -100,6 +111,9 @@ export function parseInlineDirectives(body: string): InlineDirectives { hasVerboseDirective, verboseLevel, rawVerboseLevel, + hasElevatedDirective, + elevatedLevel, + rawElevatedLevel, hasModelDirective, rawModelDirective: rawModel, hasQueueDirective, @@ -127,6 +141,7 @@ export function isDirectiveOnly(params: { if ( !directives.hasThinkDirective && !directives.hasVerboseDirective && + !directives.hasElevatedDirective && !directives.hasModelDirective && !directives.hasQueueDirective ) @@ -142,6 +157,8 @@ export async function handleDirectiveOnly(params: { sessionStore?: Record; sessionKey?: string; storePath?: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; defaultProvider: string; defaultModel: string; aliasIndex: ModelAliasIndex; @@ -161,6 +178,8 @@ export async function handleDirectiveOnly(params: { sessionStore, sessionKey, storePath, + elevatedEnabled, + elevatedAllowed, defaultProvider, defaultModel, aliasIndex, @@ -213,6 +232,17 @@ export async function handleDirectiveOnly(params: { text: `Unrecognized verbose level "${directives.rawVerboseLevel ?? ""}". Valid levels: off, on.`, }; } + if (directives.hasElevatedDirective && !directives.elevatedLevel) { + return { + text: `Unrecognized elevated level "${directives.rawElevatedLevel ?? ""}". Valid levels: off, on.`, + }; + } + if ( + directives.hasElevatedDirective && + (!elevatedEnabled || !elevatedAllowed) + ) { + return { text: "elevated is not available right now." }; + } const queueModeInvalid = directives.hasQueueDirective && @@ -296,6 +326,10 @@ export async function handleDirectiveOnly(params: { if (directives.verboseLevel === "off") delete sessionEntry.verboseLevel; else sessionEntry.verboseLevel = directives.verboseLevel; } + if (directives.hasElevatedDirective && directives.elevatedLevel) { + if (directives.elevatedLevel === "off") delete sessionEntry.elevatedLevel; + else sessionEntry.elevatedLevel = directives.elevatedLevel; + } if (modelSelection) { if (modelSelection.isDefault) { delete sessionEntry.providerOverride; @@ -344,6 +378,13 @@ export async function handleDirectiveOnly(params: { : `${SYSTEM_MARK} Verbose logging enabled.`, ); } + if (directives.hasElevatedDirective && directives.elevatedLevel) { + parts.push( + directives.elevatedLevel === "off" + ? `${SYSTEM_MARK} Elevated mode disabled.` + : `${SYSTEM_MARK} Elevated mode enabled.`, + ); + } if (modelSelection) { const label = `${modelSelection.provider}/${modelSelection.model}`; const labelWithAlias = modelSelection.alias @@ -385,6 +426,8 @@ export async function persistInlineDirectives(params: { sessionStore?: Record; sessionKey?: string; storePath?: string; + elevatedEnabled: boolean; + elevatedAllowed: boolean; defaultProvider: string; defaultModel: string; aliasIndex: ModelAliasIndex; @@ -401,6 +444,8 @@ export async function persistInlineDirectives(params: { sessionStore, sessionKey, storePath, + elevatedEnabled, + elevatedAllowed, defaultProvider, defaultModel, aliasIndex, @@ -429,6 +474,19 @@ export async function persistInlineDirectives(params: { } updated = true; } + if ( + directives.hasElevatedDirective && + directives.elevatedLevel && + elevatedEnabled && + elevatedAllowed + ) { + if (directives.elevatedLevel === "off") { + delete sessionEntry.elevatedLevel; + } else { + sessionEntry.elevatedLevel = directives.elevatedLevel; + } + updated = true; + } const modelDirective = directives.hasModelDirective && params.effectiveModelDirective ? params.effectiveModelDirective diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index 78f0e99f4..dac5b5df3 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -1,6 +1,8 @@ import { + normalizeElevatedLevel, normalizeThinkLevel, normalizeVerboseLevel, + type ElevatedLevel, type ThinkLevel, type VerboseLevel, } from "../thinking.js"; @@ -50,4 +52,26 @@ export function extractVerboseDirective(body?: string): { }; } -export type { ThinkLevel, VerboseLevel }; +export function extractElevatedDirective(body?: string): { + cleaned: string; + elevatedLevel?: ElevatedLevel; + rawLevel?: string; + hasDirective: boolean; +} { + if (!body) return { cleaned: "", hasDirective: false }; + const match = body.match( + /(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i, + ); + const elevatedLevel = normalizeElevatedLevel(match?.[1]); + const cleaned = match + ? body.replace(match[0], "").replace(/\s+/g, " ").trim() + : body.trim(); + return { + cleaned, + elevatedLevel, + rawLevel: match?.[1], + hasDirective: !!match, + }; +} + +export type { ElevatedLevel, ThinkLevel, VerboseLevel }; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 3b3e0c14e..ed2d700e2 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -78,6 +78,7 @@ export function createFollowupRunner(params: { model: queued.run.model, thinkLevel: queued.run.thinkLevel, verboseLevel: queued.run.verboseLevel, + bashElevated: queued.run.bashElevated, timeoutMs: queued.run.timeoutMs, runId, blockReplyBreak: queued.run.blockReplyBreak, diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index e2c7987aa..3c0a03d65 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -3,7 +3,7 @@ import { parseDurationMs } from "../../cli/parse-duration.js"; import type { ClawdisConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { defaultRuntime } from "../../runtime.js"; -import type { ThinkLevel, VerboseLevel } from "./directives.js"; +import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./directives.js"; export type QueueMode = | "steer" | "followup" @@ -34,6 +34,12 @@ export type FollowupRun = { model: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; + elevatedLevel?: ElevatedLevel; + bashElevated?: { + enabled: boolean; + allowed: boolean; + defaultLevel: ElevatedLevel; + }; timeoutMs: number; blockReplyBreak: "text_end" | "message_end"; ownerNumbers?: string[]; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index d07db81c0..734050e79 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -161,6 +161,8 @@ export function buildStatusMessage(args: StatusArgs): string { const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off"; const verboseLevel = args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off"; + const elevatedLevel = + args.entry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "off"; const webLine = (() => { if (args.webLinked === false) { @@ -200,7 +202,7 @@ export function buildStatusMessage(args: StatusArgs): string { contextTokens ?? null, )}${entry?.abortedLastRun ? " • last run aborted" : ""}`; - const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think , /verbose on|off, /model )`; + const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel} (set with /think , /verbose on|off, /elevated on|off, /model )`; const modelLabel = model ? `${resolved.provider}/${model}` : "unknown"; diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 60b95a17b..9a50af8ba 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,5 +1,6 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type VerboseLevel = "off" | "on"; +export type ElevatedLevel = "off" | "on"; // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel( @@ -39,3 +40,14 @@ export function normalizeVerboseLevel( if (["on", "full", "true", "yes", "1"].includes(key)) return "on"; return undefined; } + +// Normalize elevated flags used to toggle elevated bash permissions. +export function normalizeElevatedLevel( + raw?: string | null, +): ElevatedLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off", "false", "no", "0"].includes(key)) return "off"; + if (["on", "true", "yes", "1"].includes(key)) return "on"; + return undefined; +} diff --git a/src/commands/status.ts b/src/commands/status.ts index ddbb45914..e3c50ef3d 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -32,6 +32,7 @@ export type SessionStatus = { age: number | null; thinkingLevel?: string; verboseLevel?: string; + elevatedLevel?: string; systemSent?: boolean; abortedLastRun?: boolean; inputTokens?: number; @@ -108,6 +109,7 @@ export async function getStatusSummary(): Promise { age, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + elevatedLevel: entry?.elevatedLevel, systemSent: entry?.systemSent, abortedLastRun: entry?.abortedLastRun, inputTokens: entry?.inputTokens, @@ -194,6 +196,9 @@ const buildFlags = (entry: SessionEntry): string[] => { const verbose = entry?.verboseLevel; if (typeof verbose === "string" && verbose.length > 0) flags.push(`verbose:${verbose}`); + const elevated = entry?.elevatedLevel; + if (typeof elevated === "string" && elevated.length > 0) + flags.push(`elevated:${elevated}`); if (entry?.systemSent) flags.push("system"); if (entry?.abortedLastRun) flags.push("aborted"); const sessionId = entry?.sessionId as unknown; diff --git a/src/config/config.ts b/src/config/config.ts index 562caea04..2a6bec89f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -79,6 +79,15 @@ export type WebConfig = { reconnect?: WebReconnectConfig; }; +export type AgentElevatedAllowFromConfig = { + whatsapp?: string[]; + telegram?: Array; + discord?: Array; + signal?: Array; + imessage?: Array; + webchat?: Array; +}; + export type WhatsAppConfig = { /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; @@ -619,6 +628,8 @@ export type ClawdisConfig = { thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; /** Default verbose level when no /verbose directive is present. */ verboseDefault?: "off" | "on"; + /** Default elevated level when no /elevated directive is present. */ + elevatedDefault?: "off" | "on"; /** Default block streaming level when no override is present. */ blockStreamingDefault?: "off" | "on"; /** @@ -668,6 +679,13 @@ export type ClawdisConfig = { /** How long to keep finished sessions in memory (ms). */ cleanupMs?: number; }; + /** Elevated bash permissions for the host machine. */ + elevated?: { + /** Enable or disable elevated mode (default: true). */ + enabled?: boolean; + /** Approved senders for /elevated (per-surface allowlists). */ + allowFrom?: AgentElevatedAllowFromConfig; + }; /** Optional sandbox settings for non-main sessions. */ sandbox?: { /** Enable sandboxing for sessions. */ @@ -1149,6 +1167,7 @@ export const ClawdisSchema = z.object({ ]) .optional(), verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), + elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(), blockStreamingDefault: z .union([z.literal("off"), z.literal("on")]) .optional(), @@ -1180,6 +1199,21 @@ export const ClawdisSchema = z.object({ cleanupMs: z.number().int().positive().optional(), }) .optional(), + elevated: z + .object({ + enabled: z.boolean().optional(), + allowFrom: z + .object({ + whatsapp: z.array(z.string()).optional(), + telegram: z.array(z.union([z.string(), z.number()])).optional(), + discord: z.array(z.union([z.string(), z.number()])).optional(), + signal: z.array(z.union([z.string(), z.number()])).optional(), + imessage: z.array(z.union([z.string(), z.number()])).optional(), + webchat: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), + }) + .optional(), sandbox: z .object({ mode: z diff --git a/src/config/sessions.ts b/src/config/sessions.ts index a0eef9a9d..5e7d0721a 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -30,6 +30,7 @@ export type SessionEntry = { chatType?: SessionChatType; thinkingLevel?: string; verboseLevel?: string; + elevatedLevel?: string; providerOverride?: string; modelOverride?: string; groupActivation?: "mention" | "always"; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index a3badfb20..09e43e892 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -309,6 +309,7 @@ export const SessionsPatchParamsSchema = Type.Object( key: NonEmptyString, thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), sendPolicy: Type.Optional( Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 04e0998d2..b8c261013 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -18,6 +18,7 @@ import { } from "../agents/pi-embedded.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { + normalizeElevatedLevel, normalizeThinkLevel, normalizeVerboseLevel, } from "../auto-reply/thinking.js"; @@ -384,6 +385,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } + if ("elevatedLevel" in p) { + const raw = p.elevatedLevel; + if (raw === null) { + delete next.elevatedLevel; + } else if (raw !== undefined) { + const normalized = normalizeElevatedLevel(String(raw)); + if (!normalized) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid elevatedLevel: ${String(raw)}`, + }, + }; + } + next.elevatedLevel = normalized; + } + } + if ("model" in p) { const raw = p.model; if (raw === null) { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 20f22d39d..f08f5dfcc 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -36,6 +36,7 @@ export type GatewaySessionRow = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + elevatedLevel?: string; sendPolicy?: "allow" | "deny"; inputTokens?: number; outputTokens?: number; @@ -276,6 +277,7 @@ export function listSessionsFromStore(params: { abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, + elevatedLevel: entry?.elevatedLevel, sendPolicy: entry?.sendPolicy, inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, diff --git a/src/tui/commands.ts b/src/tui/commands.ts index a33332065..60b299779 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -2,6 +2,7 @@ import type { SlashCommand } from "@mariozechner/pi-tui"; const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"]; const VERBOSE_LEVELS = ["on", "off"]; +const ELEVATED_LEVELS = ["on", "off"]; const ACTIVATION_LEVELS = ["mention", "always"]; const TOGGLE = ["on", "off"]; @@ -44,6 +45,14 @@ export function getSlashCommands(): SlashCommand[] { (value) => ({ value, label: value }), ), }, + { + name: "elevated", + description: "Set elevated on/off", + getArgumentCompletions: (prefix) => + ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, { name: "activation", description: "Set group activation", @@ -78,6 +87,7 @@ export function helpText(): string { "/model (or /models)", "/think ", "/verbose ", + "/elevated ", "/activation ", "/deliver ", "/new or /reset", diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b785f6847..722ddd507 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -586,6 +586,22 @@ export async function runTui(opts: TuiOptions) { chatLog.addSystem(`verbose failed: ${String(err)}`); } break; + case "elevated": + if (!args) { + chatLog.addSystem("usage: /elevated "); + break; + } + try { + await client.patchSession({ + key: currentSessionKey, + elevatedLevel: args, + }); + chatLog.addSystem(`elevated set to ${args}`); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`elevated failed: ${String(err)}`); + } + break; case "activation": if (!args) { chatLog.addSystem("usage: /activation "); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 3091d5496..0f1654658 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -194,6 +194,7 @@ export type GatewaySessionRow = { abortedLastRun?: boolean; thinkingLevel?: string; verboseLevel?: string; + elevatedLevel?: string; inputTokens?: number; outputTokens?: number; totalTokens?: number; @@ -218,6 +219,7 @@ export type SessionsPatchResult = { updatedAt?: number; thinkingLevel?: string; verboseLevel?: string; + elevatedLevel?: string; }; };