feat: add elevated ask/full modes

This commit is contained in:
Peter Steinberger
2026-01-22 05:32:13 +00:00
parent 5567bceb66
commit a2981c5a2c
29 changed files with 115 additions and 57 deletions

View File

@@ -1981,7 +1981,7 @@ Per-agent override (further restrict):
Notes:
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
- `/elevated on|off` stores state per session key; inline directives apply to a single message.
- `/elevated on|off|ask|full` stores state per session key; inline directives apply to a single message.
- Elevated `exec` runs on the host and bypasses sandboxing.
- Tool policy still applies; if `exec` is denied, elevated cannot be used.

View File

@@ -91,7 +91,8 @@ Available groups:
## Elevated: exec-only “run on host”
Elevated does **not** grant extra tools; it only affects `exec`.
- If youre sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
- If youre sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host (approvals may still apply).
- Use `/elevated full` to skip exec approvals for the session.
- If youre already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.

View File

@@ -216,7 +216,7 @@ Option B:
## Slash commands
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
- Per-agent, per-session overrides; non-persistent unless saved via config.
- `/elevated on|off` remains a shortcut for `host=gateway security=full`.
- `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals).
## Cross-platform story
- The runner service is the portable execution target.

View File

@@ -6,17 +6,20 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- 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.
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesnt)
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` inside a message applies to that message only.
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
@@ -26,8 +29,8 @@ read_when:
3. Global default (`agents.defaults.elevatedDefault` in config).
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`.
- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
@@ -41,4 +44,4 @@ read_when:
## Logging + status
- Elevated exec calls are logged at info level.
- Session status includes elevated mode (e.g. `elevated=on`).
- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`).

View File

@@ -11,7 +11,7 @@ read_when:
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating.
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny).

View File

@@ -78,7 +78,7 @@ Text + native (when enabled):
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)

View File

@@ -78,7 +78,7 @@ Session controls:
- `/verbose <on|full|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>`
- `/elevated <on|off>` (alias: `/elev`)
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`
- `/deliver <on|off>`

View File

@@ -140,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecElevatedDefaults = {
enabled: boolean;
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
const execSchema = Type.Object({
@@ -706,12 +706,23 @@ export function createExecTool(
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn =
elevatedDefaults?.defaultLevel === "on" &&
elevatedDefaults.enabled &&
elevatedDefaults.allowed;
const elevatedRequested =
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "full"
? "full"
: elevatedDefaults?.defaultLevel === "ask"
? "ask"
: elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: elevatedDefaultMode;
const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
@@ -767,6 +778,10 @@ export function createExecTool(
const configuredAsk = defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off";
}
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
@@ -1031,7 +1046,7 @@ export function createExecTool(
};
}
if (host === "gateway") {
if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);

View File

@@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
};

View File

@@ -322,7 +322,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("You are running in a sandboxed runtime");
expect(prompt).toContain("Sub-agents stay sandboxed");
expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("User can toggle with /elevated on|off|ask|full.");
expect(prompt).toContain("Current elevated level: on");
});

View File

@@ -176,7 +176,7 @@ export function buildAgentSystemPrompt(params: {
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
@@ -444,12 +444,14 @@ export function buildAgentSystemPrompt(params: {
params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session."
: "",
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
params.sandboxInfo.elevated?.allowed
? "You may also send /elevated on|off when needed."
? "User can toggle with /elevated on|off|ask|full."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
? "You may also send /elevated on|off|ask|full when needed."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
: "",
]
.filter(Boolean)

View File

@@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
args: [
{
name: "mode",
description: "on or off",
description: "on, off, ask, or full",
type: "string",
choices: ["on", "off"],
choices: ["on", "off", "ask", "full"],
},
],
argsMenu: "auto",

View File

@@ -219,7 +219,7 @@ describe("directive behavior", () => {
);
const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events.some((e) => e.includes("Elevated ON"))).toBe(true);
expect(events.some((e) => e.includes("Elevated ASK"))).toBe(true);
});
});
it("queues a system event when toggling reasoning", async () => {

View File

@@ -150,7 +150,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -143,7 +143,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off.");
expect(text).toContain("Options: on, off, ask, full.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -55,6 +55,16 @@ describe("directive parsing", () => {
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("on");
});
it("matches elevated ask", () => {
const res = extractElevatedDirective("/elevated ask please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("ask");
});
it("matches elevated full", () => {
const res = extractElevatedDirective("/elevated full please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("full");
});
it("matches think at start of line", () => {
const res = extractThinkDirective("/think:high run slow");

View File

@@ -129,7 +129,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -223,7 +223,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled");
expect(text).not.toContain("Elevated mode set to ask");
});
});
});

View File

@@ -184,7 +184,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -226,7 +226,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@@ -167,7 +167,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@@ -120,7 +120,7 @@ async function resolveContextReport(
workspaceAccess: "rw" as const,
elevated: {
allowed: params.elevated.allowed,
defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const),
defaultLevel: (params.resolvedElevatedLevel ?? "off") as "on" | "off" | "ask" | "full",
},
}
: { enabled: false };

View File

@@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
const level = currentElevatedLevel ?? "off";
return {
text: [
withOptions(`Current elevated level: ${level}.`, "on, off"),
withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
]
.filter(Boolean)
@@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
};
}
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`,
};
}
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
@@ -426,7 +426,9 @@ export async function handleDirectiveOnly(params: {
parts.push(
directives.elevatedLevel === "off"
? formatDirectiveAck("Elevated mode disabled.")
: formatDirectiveAck("Elevated mode enabled."),
: directives.elevatedLevel === "full"
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}

View File

@@ -16,10 +16,15 @@ export const withOptions = (line: string, options: string) =>
export const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
export const formatElevatedEvent = (level: ElevatedLevel) =>
level === "on"
? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed."
: "Elevated OFF — exec stays in sandbox.";
export const formatElevatedEvent = (level: ElevatedLevel) => {
if (level === "full") {
return "Elevated FULL — exec runs on host with auto-approval.";
}
if (level === "ask" || level === "on") {
return "Elevated ASK — exec runs on host; approvals may still apply.";
}
return "Elevated OFF — exec stays in sandbox.";
};
export const formatReasoningEvent = (level: ReasoningLevel) => {
if (level === "stream") return "Reasoning STREAM — emit live <think>.";

View File

@@ -324,7 +324,12 @@ export function buildStatusMessage(args: StatusArgs): string {
const queueDetails = formatQueueDetails(args.queue);
const verboseLabel =
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
const elevatedLabel = elevatedLevel === "on" ? "elevated" : null;
const elevatedLabel =
elevatedLevel && elevatedLevel !== "off"
? elevatedLevel === "on"
? "elevated"
: `elevated:${elevatedLevel}`
: null;
const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
@@ -395,7 +400,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
"/think <level>",
"/verbose on|full|off",
"/reasoning on|off",
"/elevated on|off",
"/elevated on|off|ask|full",
"/model <id>",
"/usage off|tokens|full",
];

View File

@@ -1,6 +1,7 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on";
export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "tokens" | "full";
@@ -112,10 +113,18 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
if (["on", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
if (!level || level === "off") return "off";
if (level === "full") return "full";
return "ask";
}
// Normalize reasoning visibility flags used to toggle reasoning exposure.
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
if (!raw) return undefined;

View File

@@ -136,7 +136,7 @@ export type AgentDefaultsConfig = {
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on" | "full";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
elevatedDefault?: "off" | "on" | "ask" | "full";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**

View File

@@ -113,7 +113,9 @@ export const AgentDefaultsSchema = z
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z
.union([z.literal("off"), z.literal("on"), z.literal("ask"), z.literal("full")])
.optional(),
blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(),
blockStreamingChunk: BlockStreamingChunkSchema.optional(),

View File

@@ -169,7 +169,7 @@ export async function applySessionsPatchToStore(params: {
delete next.elevatedLevel;
} else if (raw !== undefined) {
const normalized = normalizeElevatedLevel(String(raw));
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")');
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
// Persist "off" explicitly so patches can override defaults.
next.elevatedLevel = normalized;
}

View File

@@ -3,7 +3,7 @@ import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thi
const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off", "ask", "full"];
const ACTIVATION_LEVELS = ["mention", "always"];
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"];
@@ -83,7 +83,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
},
{
name: "elevated",
description: "Set elevated on/off",
description: "Set elevated on/off/ask/full",
getArgumentCompletions: (prefix) =>
ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
@@ -130,8 +130,8 @@ export function helpText(options: SlashCommandOptions = {}): string {
"/verbose <on|off>",
"/reasoning <on|off>",
"/usage <off|tokens|full>",
"/elevated <on|off>",
"/elev <on|off>",
"/elevated <on|off|ask|full>",
"/elev <on|off|ask|full>",
"/activation <mention|always>",
"/new or /reset",
"/abort",

View File

@@ -371,7 +371,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}
case "elevated":
if (!args) {
chatLog.addSystem("usage: /elevated <on|off>");
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
if (!["on", "off", "ask", "full"].includes(args)) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
try {