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

@@ -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 {