Rename YOLO mode to full access

This commit is contained in:
Pranit committed 2026-09-05 12:04:45 -04:00
1 parent 3f7a438943
commit 12d9549a4c
26 files changed
+244 -107

No files matched your search

+1 -1
View File
@@ -134,7 +134,7 @@ Runtime state lives under `~/.fx/sessions/<session-id>/` (`session.json`, `backg
Security is permission-first. All sensitive tool behavior must integrate with `src/core/permissions/permissions.zig`.
* `permission_mode` controls baseline (`ask`, `auto`, or `yolo`). Yolo bypasses fx permission policy and uses an effective sandbox of `none` without rewriting saved sandbox configuration
* `permission_mode` controls baseline (`ask`, `auto`, or `full-access`; `yolo` remains an alias). Full access bypasses fx permission policy and uses an effective sandbox of `none` without rewriting saved sandbox configuration
* Configured denies are evaluated before saved-session rules; an exact saved-session deny can narrow a configured allow, while an exact saved-session allow can satisfy an unresolved configured ask
+2 -2
View File
@@ -331,7 +331,7 @@ capability. Missing, revoked, stale, or closed authority fails before transport.
Security is permission-first.
* `permission_mode` controls baseline behavior (`ask`, `auto`, or `yolo`)
* `permission_mode` controls baseline behavior (`ask`, `auto`, or `full-access`; `yolo` remains an alias)
* `permission` config applies OpenCode-style wildcard rules
@@ -349,7 +349,7 @@ Security is permission-first.
* exact cautions and deterministic incomplete-evidence results are cached only for the current turn; an unavailable outcome is not cached as a security judgment, but the same exact action spends at most one unavailable transport attempt per turn and changed actions remain independently reviewable until the bounded current-turn transport budget is exhausted. Legacy `permission_request_id` input is rejected without prompting
* the sandbox backend is configured independently; yolo uses an effective backend of `none` without rewriting the saved sandbox setting
* the sandbox backend is configured independently; full access uses an effective backend of `none` without rewriting the saved sandbox setting
Do not add new sensitive tool behavior without integrating it into `src/core/permissions/permissions.zig`.
+2
View File
@@ -129,6 +129,8 @@ Invalid Shell requests return the specific argument problems before any command
fx starts in `auto` permission mode. Routine understood development actions run directly. Each unresolved action receives one narrow review of the exact pending action for concrete security danger. Prepared file mutations and static tools are reviewed without task text; reviewed commands, dynamic tools, and delegated actions also receive bounded trusted root-request context. A clear result authorizes only that action. A caution or unavailable review holds the action and returns advice to the agent without opening a permission prompt or ending the turn. See [Permissions](https://fx.sh/docs/configure-fx/permissions) for other modes and persistent rules.
Use `fx ask --full-access` or `/permissions full-access` to disable fx permission checks for trusted environments. The former `--yolo` flag and `/permissions yolo` command remain supported. `FX_PERMISSION_MODE` and profile `permission_mode` accept `full-access`; saved settings and JSON output retain `yolo` for compatibility.
JSON and quiet requests stay noninteractive by default. Add `--prompt-permissions` to allow configured approval prompts when stdin is a TTY. Automatic safety review never opens that prompt. Prompt text is written to stderr, so JSON stdout stays parseable and quiet stdout stays empty. Piped or redirected stdin remains noninteractive and fails instead of waiting for approval.
Inside a saved session, `/permissions remember <allow|deny> <tool-name> <arguments-json>` stores an exact confirmed rule without running the action. `/permissions` lists stable rule IDs, and `/permissions revoke <rule-id>` removes a stored rule even when its original workspace or file state has changed.
+8 -7
View File
@@ -30,11 +30,12 @@ pub const top_level_specs = [_]TopLevelSpec{
.{
.kind = .ask,
.token = "ask",
.usage = "ask [--auto|--yolo] [--image PATH] [--system TEXT] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--continue-recovery] [--] <prompt>",
.usage = "ask [--auto|--full-access] [--image PATH] [--system TEXT] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--continue-recovery] [--] <prompt>",
.summary = "Run one noninteractive request",
.options = &.{
.{ .flag = "--auto", .description = "Automatically review unresolved permission requests" },
.{ .flag = "--yolo", .description = "Disable fx permission checks" },
.{ .flag = "--full-access", .description = "Disable fx permission checks" },
.{ .flag = "--yolo", .description = "Alias for --full-access" },
.{ .flag = "--image PATH", .description = "Attach an image file; repeat for multiple images" },
.{ .flag = "--system TEXT", .description = "Replace the built-in system prompt for this request" },
json_option,
@@ -121,11 +122,11 @@ pub const top_level_specs = [_]TopLevelSpec{
.options = &.{json_option},
.details = &.{
"Modes:",
" ask Prompt before sensitive tool calls",
" auto Apply rules, then review unresolved sensitive tool calls (default)",
" yolo Disable fx permission checks",
" ask Prompt before sensitive tool calls",
" auto Apply rules, then review unresolved sensitive tool calls (default)",
" full-access Disable fx permission checks",
"",
"Change the mode from the interactive shell with `/permissions [ask|auto|yolo|reset]`,",
"Change the mode from the interactive shell with `/permissions [ask|auto|full-access|reset]`,",
"and manage persistent allow rules with `/allowlist`.",
},
},
@@ -436,7 +437,7 @@ pub const slash_specs = [_]SlashSpec{
.{ .kind = .image, .command = "/image", .aliases = &.{"/img"}, .help_entry = "/image <path> (/img)", .completion_description = "attach an image by path", .presentation_category = .media, .has_args = true, .accepts_payload = true },
.{ .kind = .images, .command = "/images", .help_entry = "/images [clear]", .completion_description = "manage pending image attachments", .presentation_category = .media, .has_args = true, .accepts_payload = true },
.{ .kind = .model, .command = "/model", .help_entry = "/model <id-or-query>", .completion_description = "choose what model and reasoning effort to use", .presentation_category = .model, .has_args = true, .accepts_payload = true },
.{ .kind = .permissions, .command = "/permissions", .help_entry = "/permissions [ask|auto|yolo|reset]", .completion_description = "choose what fx is allowed to do", .presentation_category = .security, .show_in_welcome = true, .has_args = true, .accepts_payload = true },
.{ .kind = .permissions, .command = "/permissions", .help_entry = "/permissions [ask|auto|full-access|reset]", .completion_description = "choose what fx is allowed to do", .presentation_category = .security, .show_in_welcome = true, .has_args = true, .accepts_payload = true },
.{ .kind = .allowlist, .command = "/allowlist", .help_entry = "/allowlist [view [effective|local|user]|[local|user] add|remove|reset ...]", .completion_description = "manage trusted commands, tools, and URLs", .presentation_category = .security, .show_in_welcome = true, .has_args = true, .accepts_payload = true },
.{ .kind = .undo, .command = "/undo", .help_entry = "/undo", .completion_description = "undo the latest tracked file operation", .presentation_category = .session },
.{ .kind = .mcp, .command = "/mcp", .help_entry = "/mcp [list|resource|prompt|add|remove|path|reload|auth|logout|trust]", .completion_description = "manage local and remote MCP servers, resources, prompts, and project trust", .presentation_category = .extensions, .has_args = true, .accepts_payload = true },
+1 -1
View File
@@ -2824,7 +2824,7 @@ fn permissionModeContext(permission_mode: types.PermissionMode) []const u8 {
return switch (permission_mode) {
.ask => "Runtime context: permission mode is ask. Sensitive tool calls may require user approval unless configured rules or session grants already decide them. Tool admission remains authoritative.",
.auto => "Runtime context: permission mode is auto. After configured rules, session grants, and deterministic safe-tool authority, fx sends each unresolved action to a narrow safety reviewer. A clear result authorizes only that exact action. A caution or unavailable result holds only that action and returns advice without opening a permission screen, disabling tools, or ending the turn. Exact cautions are reused for this turn; choose a materially different safe action or explain why no safe path remains. Tool admission and exact live revalidation remain authoritative.",
.yolo => "Runtime context: permission mode is yolo. fx permission policy is disabled. Tool lookup, argument validation, execution authority, cancellation, limits, operating-system permissions, and remote authentication remain authoritative.",
.yolo => "Runtime context: permission mode is full access. fx permission policy is disabled. Tool lookup, argument validation, execution authority, cancellation, limits, operating-system permissions, and remote authentication remain authoritative.",
};
}
+2 -2
View File
@@ -17,7 +17,7 @@ pub const State = struct {
};
pub const yolo_warning_text = permissions.yolo_warning_text;
pub const yolo_warning_compact_text = "YOLO: unrestricted";
pub const yolo_warning_compact_text = "Full access";
const yolo_warning_visible_ms: i64 = 4000;
pub fn monotonicMillis() i64 {
@@ -205,7 +205,7 @@ pub fn Runtime(comptime App: type) type {
);
session_commands.reportUserSettingsFailure(
app,
"yolo-acknowledgment",
"full-access-acknowledgment",
failure.err,
failure.cleanup,
true,
+44 -19
View File
@@ -3597,7 +3597,7 @@ fn parseOptionsWithStdin(alloc: Allocator, args: []const [:0]const u8, stdin: St
} else if (std.mem.eql(u8, arg, "--auto")) {
if (opts.permission_override != null) return error.InvalidAskArgs;
opts.permission_override = .auto;
} else if (std.mem.eql(u8, arg, "--yolo")) {
} else if (std.mem.eql(u8, arg, "--full-access") or std.mem.eql(u8, arg, "--yolo")) {
if (opts.permission_override != null) return error.InvalidAskArgs;
opts.permission_override = .yolo;
} else if (std.mem.eql(u8, arg, "--resume") or std.mem.eql(u8, arg, "--resume-id")) {
@@ -3682,7 +3682,7 @@ fn emitHeadlessYoloWarning(alloc: Allocator, options: RunOptions) !void {
var message: std.Io.Writer.Allocating = .init(alloc);
defer message.deinit();
try message.writer.print(
"fx ask: failed to save YOLO acknowledgment: {s}\n",
"fx ask: failed to save full access acknowledgment: {s}\n",
.{@errorName(failure.err)},
);
try options.deps.write_stderr(options.deps.stderr_ctx, message.written());
@@ -4097,7 +4097,7 @@ fn testModelPromptOverlay(model: []const u8) ?[]const u8 {
fn testConfig() Config {
return .{
.command_usage = "ask [--auto|--yolo] [--image PATH] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--] <prompt>",
.command_usage = "ask [--auto|--full-access] [--image PATH] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--] <prompt>",
.default_model = "model",
.default_agent_step_limit = 4,
.gateway_retry_count = 1,
@@ -5153,23 +5153,48 @@ test "parse options preserves active ask flags and operands" {
try std.testing.expectEqualStrings("hello world", options.prompt);
}
test "parse options accepts yolo and rejects permission flag conflicts" {
var yolo = try parseOptionsWithStdin(
std.testing.allocator,
&.{ "--yolo", "hello" },
.tty,
);
defer yolo.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(?PermissionMode, .yolo), yolo.permission_override);
test "parse options accepts full access aliases and rejects permission flag conflicts" {
const alloc = std.testing.allocator;
const aliases = [_][:0]const u8{ "--full-access", "--yolo" };
for (aliases) |alias| {
var options = try parseOptionsWithStdin(alloc, &.{ alias, "hello" }, .tty);
defer options.deinit(alloc);
try std.testing.expectEqual(@as(?PermissionMode, .yolo), options.permission_override);
try std.testing.expectEqualStrings("hello", options.prompt);
}
try std.testing.expectError(
error.InvalidAskArgs,
parseOptionsWithStdin(
std.testing.allocator,
&.{ "--auto", "--yolo", "hello" },
.tty,
),
);
const permission_flags = [_][:0]const u8{ "--auto", "--full-access", "--yolo" };
for (permission_flags) |first| {
for (permission_flags) |second| {
try std.testing.expectError(
error.InvalidAskArgs,
parseOptionsWithStdin(alloc, &.{ first, second, "hello" }, .tty),
);
}
}
}
test "parse options preserves full access aliases after the delimiter as prompt text" {
const alloc = std.testing.allocator;
var literal = try parseOptionsWithStdin(alloc, &.{ "--", "--full-access", "--yolo", "--auto" }, .tty);
defer literal.deinit(alloc);
try std.testing.expectEqual(@as(?PermissionMode, null), literal.permission_override);
try std.testing.expectEqualStrings("--full-access --yolo --auto", literal.prompt);
const cases = [_]struct {
flag: [:0]const u8,
mode: PermissionMode,
}{
.{ .flag = "--auto", .mode = .auto },
.{ .flag = "--full-access", .mode = .yolo },
.{ .flag = "--yolo", .mode = .yolo },
};
for (cases) |case| {
var options = try parseOptionsWithStdin(alloc, &.{ case.flag, "--", "--full-access", "--yolo", "--auto" }, .tty);
defer options.deinit(alloc);
try std.testing.expectEqual(@as(?PermissionMode, case.mode), options.permission_override);
try std.testing.expectEqualStrings("--full-access --yolo --auto", options.prompt);
}
}
test "headless yolo warning reaches stderr before acknowledgment persistence" {
+1 -1
View File
@@ -687,7 +687,7 @@ fn permissionModeLabel(mode: types.PermissionMode) []const u8 {
return switch (mode) {
.ask => "ask",
.auto => "auto",
.yolo => "yolo",
.yolo => "full access",
};
}
+6 -4
View File
@@ -772,10 +772,7 @@ pub fn ensureStateLayout(paths: Paths) !void {
}
pub fn parsePermissionMode(raw: []const u8) ?types.PermissionMode {
if (std.ascii.eqlIgnoreCase(raw, "ask")) return .ask;
if (std.ascii.eqlIgnoreCase(raw, "auto")) return .auto;
if (std.ascii.eqlIgnoreCase(raw, "yolo")) return .yolo;
return null;
return types.PermissionMode.parse(raw);
}
pub fn parsePermissionAction(raw: []const u8) ?types.PermissionAction {
@@ -2454,6 +2451,11 @@ test "parsePermissionMode accepts only exact case-insensitive labels" {
try std.testing.expectEqual(types.PermissionMode.auto, parsePermissionMode("Auto").?);
try std.testing.expectEqual(types.PermissionMode.yolo, parsePermissionMode("yolo").?);
try std.testing.expectEqual(types.PermissionMode.yolo, parsePermissionMode("YOLO").?);
try std.testing.expectEqual(types.PermissionMode.yolo, parsePermissionMode("full-access").?);
try std.testing.expectEqual(types.PermissionMode.yolo, parsePermissionMode("Full Access").?);
try std.testing.expect(parsePermissionMode("full") == null);
try std.testing.expect(parsePermissionMode(" full-access") == null);
try std.testing.expect(parsePermissionMode("full-access ") == null);
try std.testing.expect(parsePermissionMode(" default") == null);
try std.testing.expect(parsePermissionMode("danger") == null);
}
+11 -3
View File
@@ -65,7 +65,7 @@ pub const Snapshot = struct {
.model => self.model,
.effort => self.effort,
.fast_mode => onOff(self.fast_mode),
.permission_mode => self.permission_mode,
.permission_mode => if (std.mem.eql(u8, self.permission_mode, "yolo")) "full access" else self.permission_mode,
.statusline_context => onOff(self.statusline_context),
.statusline_session => onOff(self.statusline_session),
.statusline_workspace => onOff(self.statusline_workspace),
@@ -267,7 +267,7 @@ const specs = [_]Spec{
};
const on_off_options = [_][]const u8{ "off", "on" };
const permission_options = [_][]const u8{ "ask", "auto", "yolo" };
const permission_options = [_][]const u8{ "ask", "auto", "full access" };
const sound_level_options = [_][]const u8{ "off", "on", "max" };
pub fn filteredCount(snapshot: Snapshot, category: Category, query: []const u8) usize {
@@ -446,6 +446,14 @@ test "settings catalog projects grouped searchable preferences" {
try std.testing.expect(itemAt(snapshot, .all, "missing preference", 0) == null);
}
test "settings catalog displays legacy yolo as full access and cycles from it" {
const snapshot: Snapshot = .{ .permission_mode = "yolo" };
try std.testing.expectEqualStrings("full access", snapshot.value(.permission_mode));
try std.testing.expectEqual(@as(usize, 2), selectedOptionIndex(&snapshot, .permission_mode).?);
try std.testing.expectEqualStrings("ask", cycleChange(&snapshot, .permission_mode, 1).?.value);
try std.testing.expectEqualStrings("auto", cycleChange(&snapshot, .permission_mode, -1).?.value);
}
test "settings catalog returns typed edit choices without effects" {
const efforts = [_]types.ReasoningEffort{
types.ReasoningEffort.literal("future-tier"),
@@ -466,7 +474,7 @@ test "settings catalog returns typed edit choices without effects" {
try std.testing.expect(changeAt(&snapshot, .model, 0) == null);
try std.testing.expectEqual(@as(usize, 3), optionCount(&snapshot, .permission_mode));
try std.testing.expectEqualStrings("yolo", optionAt(&snapshot, .permission_mode, 2).?);
try std.testing.expectEqualStrings("full access", optionAt(&snapshot, .permission_mode, 2).?);
try std.testing.expectEqual(@as(usize, 3), optionCount(&snapshot, .effort));
try std.testing.expectEqualStrings("default", optionAt(&snapshot, .effort, 0).?);
try std.testing.expectEqualStrings("future-tier", optionAt(&snapshot, .effort, 1).?);
+24 -5
View File
@@ -1852,11 +1852,7 @@ fn validateKnownSettingsObject(
}
}
if (object.get("permission_mode")) |value| {
if (value != .string or
(!std.ascii.eqlIgnoreCase(value.string, "ask") and
!std.ascii.eqlIgnoreCase(value.string, "auto") and
!std.ascii.eqlIgnoreCase(value.string, "yolo")))
{
if (value != .string or types.PermissionMode.parse(value.string) == null) {
return error.InvalidSettingsFormat;
}
}
@@ -2075,6 +2071,29 @@ fn writeStoreFixture(dir: std.Io.Dir, sub_path: []const u8, text: []const u8) !v
try file.writeStreamingAll(io_mod.getIo(), text);
}
test "user patch accepts existing full access aliases and preserves their spelling" {
const alloc = std.testing.allocator;
for ([_][]const u8{ "full-access", "Full Access", "yolo" }) |mode| {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(io_mod.getIo(), "home/.fx");
const original = try std.fmt.allocPrint(alloc, "{{\"permission_mode\":\"{s}\"}}\n", .{mode});
defer alloc.free(original);
try writeStoreFixture(tmp.dir, "home/.fx/settings.json", original);
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home");
defer alloc.free(home);
var store = try Store.initFromHome(alloc, home, .writable);
defer store.deinit(alloc);
var outcome = try store.applyUserPatch(alloc, .{ .yolo_acknowledged = true });
defer outcome.deinit(alloc);
try std.testing.expect(outcome == .committed);
const bytes = try store.readPrimaryForTest(alloc);
defer alloc.free(bytes);
try std.testing.expect(std.mem.find(u8, bytes, mode) != null);
try std.testing.expect(std.mem.find(u8, bytes, "\"yolo_acknowledged\":true") != null);
}
}
test "user patch writes user preferences at top level" {
const alloc = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
+5 -5
View File
@@ -528,7 +528,7 @@ pub const StatusSnapshot = struct {
if (self.auth.team) |team| {
try out.writer.print("[status] team={s}\n", .{team});
}
try out.writer.print("[status] permission_mode={s}\n", .{permissionModeLabel(self.permission_mode)});
try out.writer.print("[status] permission_mode={s}\n", .{permissions.permissionModeDisplayLabel(self.permission_mode)});
try out.writer.print("[status] workspace={s}\n", .{self.workspace_root});
try out.writer.print("[status] history_turns={d}\n", .{self.history_turns});
try out.writer.print("[status] session_permission_grants={d}\n", .{self.session_permission_grants});
@@ -560,7 +560,7 @@ pub const StatusSnapshot = struct {
if (self.auth.expired) try out.writer.writeAll("auth_expired=true\n");
if (self.auth_help) |help| try out.writer.print("auth_help={s}\n", .{help});
if (self.auth.team) |team| try out.writer.print("team={s}\n", .{team});
try out.writer.print("permission_mode={s}\n", .{permissionModeLabel(self.permission_mode)});
try out.writer.print("permission_mode={s}\n", .{permissions.permissionModeDisplayLabel(self.permission_mode)});
try out.writer.print("workspace={s}\n", .{self.workspace_root});
try out.writer.print("history_turns={d}\n", .{self.history_turns});
try out.writer.print("session_permission_grants={d}\n", .{self.session_permission_grants});
@@ -670,7 +670,7 @@ pub const PermissionsSnapshot = struct {
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
try out.writer.print("[permissions] mode={s}\n", .{permissionModeLabel(self.mode)});
try out.writer.print("[permissions] mode={s}\n", .{permissions.permissionModeDisplayLabel(self.mode)});
try writePermissionRulesText(&out.writer, self.rules);
if (self.grants.len == 0) {
try out.writer.writeAll("[permissions] session grants: (none)\n");
@@ -691,7 +691,7 @@ pub const PermissionsSnapshot = struct {
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
try out.writer.print("mode={s}\n", .{permissionModeLabel(self.mode)});
try out.writer.print("mode={s}\n", .{permissions.permissionModeDisplayLabel(self.mode)});
if (self.rules.rules.len == 0) {
try out.writer.writeAll("configured rules: (none)\n");
} else {
@@ -1341,7 +1341,7 @@ pub const DoctorSnapshot = struct {
if (self.auth.team) |team| {
try out.writer.print("[doctor] team={s}\n", .{team});
}
try out.writer.print("[doctor] permission_mode={s}\n", .{permissionModeLabel(self.permission_mode)});
try out.writer.print("[doctor] permission_mode={s}\n", .{permissions.permissionModeDisplayLabel(self.permission_mode)});
try out.writer.print("[doctor] agent_step_limit={d}\n", .{self.agent_step_limit});
if (self.mcp) |mcp| try mcp.writeText(&out.writer, alloc, "doctor");
+12 -1
View File
@@ -94,7 +94,7 @@ pub const PermissionTargetKind = enum {
pub const web_search_permission = "web_search";
pub const web_fetch_permission = "web_fetch";
pub const yolo_warning_text = "YOLO enabled: fx permission checks disabled";
pub const yolo_warning_text = "Full access enabled: fx permission checks disabled";
pub fn isWebSearchToolName(tool_name: []const u8) bool {
return std.mem.eql(u8, tool_name, web_search_permission);
@@ -112,6 +112,15 @@ pub fn permissionModeLabel(mode: PermissionMode) []const u8 {
};
}
/// Human-readable name; permissionModeLabel retains the persisted and wire value.
pub fn permissionModeDisplayLabel(mode: PermissionMode) []const u8 {
return switch (mode) {
.ask => "ask",
.auto => "auto",
.yolo => "full access",
};
}
pub fn permissionDecisionFromIndex(index: u8) ToolPermissionDecision {
return switch (index) {
0 => .once,
@@ -1957,6 +1966,8 @@ test "PermissionEngine stores deduplicates clears replaces and deinitializes own
test "permissionModeLabel maps active permission mode labels" {
try std.testing.expectEqualStrings("ask", permissionModeLabel(.ask));
try std.testing.expectEqualStrings("auto", permissionModeLabel(.auto));
try std.testing.expectEqualStrings("yolo", permissionModeLabel(.yolo));
try std.testing.expectEqualStrings("full access", permissionModeDisplayLabel(.yolo));
}
test "permissionDecisionFromIndex maps approval choices" {
+17 -21
View File
@@ -22,7 +22,7 @@ const render_request = @import("../../ui/render_request.zig");
const freeStringList = collections.freeStringList;
const containsIgnoreCase = text_utils.containsIgnoreCase;
const permissions_usage = "usage: /permissions [ask|auto|yolo|reset]\n /permissions remember <allow|deny> <tool-name> <arguments-json>\n /permissions revoke <rule-id>";
const permissions_usage = "usage: /permissions [ask|auto|full-access|reset]\n /permissions remember <allow|deny> <tool-name> <arguments-json>\n /permissions revoke <rule-id>";
pub fn reportUserSettingsCommit(
app: anytype,
@@ -351,21 +351,17 @@ pub fn Commands(comptime App: type) type {
return;
}
if (std.ascii.eqlIgnoreCase(rest, "ask")) {
try app_permission_runtime.Runtime(App).selectMode(app, .ask);
try app.writeDomainNotice(.{ .topic = "permissions", .tone = .neutral, .body = "mode set to ask" }, true);
return;
}
if (std.ascii.eqlIgnoreCase(rest, "auto")) {
try app_permission_runtime.Runtime(App).selectMode(app, .auto);
try app.writeDomainNotice(.{ .topic = "permissions", .tone = .neutral, .body = "mode set to auto" }, true);
return;
}
if (std.ascii.eqlIgnoreCase(rest, "yolo")) {
try app_permission_runtime.Runtime(App).selectMode(app, .yolo);
try app.writeDomainNotice(.{ .topic = "permissions", .tone = .warning, .body = "mode set to yolo" }, true);
if (config_runtime.parsePermissionMode(rest)) |mode| {
try app_permission_runtime.Runtime(App).selectMode(app, mode);
try app.writeDomainNotice(.{
.topic = "permissions",
.tone = if (mode == .yolo) .warning else .neutral,
.body = switch (mode) {
.ask => "mode set to ask",
.auto => "mode set to auto",
.yolo => "mode set to full access",
},
}, true);
return;
}
@@ -934,7 +930,7 @@ pub fn Commands(comptime App: type) type {
const msg = try std.fmt.allocPrint(app.alloc, "model: {s}\nmodel_config_source: {s}\npermission_mode: {s}\nworkspace: {s}\nstep_limit: {d}\nstartup_scrollback: {s}", .{
provider_runtime.model(app),
@tagName(detailed.sources.models.get(.gateway)),
permissions.permissionModeLabel(app.permission_engine.mode),
permissions.permissionModeDisplayLabel(app.permission_engine.mode),
app.workspace_root,
app.agent_step_limit,
startup_scrollback_label,
@@ -1937,7 +1933,7 @@ test "session_commands showStatus writes session status snapshot" {
app.clearTranscript();
app.permission_engine.mode = .yolo;
try Commands(FakeApp).showStatus(&app);
try expectTranscriptContains(&app, "permission_mode=yolo\n");
try expectTranscriptContains(&app, "permission_mode=full access\n");
}
test "session_commands history setting toggles durable input history" {
@@ -2237,7 +2233,7 @@ test "session_commands handlePermissions persists modes and reset clears session
try std.testing.expectEqual(types.PermissionMode.yolo, app.worker.synced_mode.?);
try std.testing.expectEqual(@as(usize, 2), app.permission_mode_preference_commit_count);
try std.testing.expectEqual(@as(?types.PermissionMode, .yolo), app.last_preference_permission_mode);
try expectTranscriptContains(&app, "mode set to yolo");
try expectTranscriptContains(&app, "mode set to full access");
app.clearTranscript();
try Commands(FakeApp).handlePermissions(&app, "ask");
@@ -2290,11 +2286,11 @@ test "session_commands handlePermissions reports usage and invalid action before
defer app.deinit();
try Commands(FakeApp).handlePermissions(&app, "add");
try expectTranscriptContains(&app, "usage: /permissions [ask|auto|yolo|reset]");
try expectTranscriptContains(&app, "usage: /permissions [ask|auto|full-access|reset]");
app.clearTranscript();
try Commands(FakeApp).handlePermissions(&app, "remove");
try expectTranscriptContains(&app, "usage: /permissions [ask|auto|yolo|reset]");
try expectTranscriptContains(&app, "usage: /permissions [ask|auto|full-access|reset]");
try std.testing.expectEqual(@as(usize, 0), app.permission_mode_preference_commit_count);
}
+9
View File
@@ -1963,6 +1963,15 @@ pub const PermissionMode = enum {
ask,
auto,
yolo,
pub fn parse(raw: []const u8) ?PermissionMode {
if (std.ascii.eqlIgnoreCase(raw, "ask")) return .ask;
if (std.ascii.eqlIgnoreCase(raw, "auto")) return .auto;
if (std.ascii.eqlIgnoreCase(raw, "full-access") or
std.ascii.eqlIgnoreCase(raw, "full access") or
std.ascii.eqlIgnoreCase(raw, "yolo")) return .yolo;
return null;
}
};
pub const RuleDecision = enum {
+5 -5
View File
@@ -743,7 +743,7 @@ const permissions_arg_completions = [_][]const u8{
"/permissions auto",
"/permissions remember",
"/permissions revoke",
"/permissions yolo",
"/permissions full-access",
"/permissions reset",
};
@@ -2074,15 +2074,15 @@ test "slash completions list permission modes and rule management" {
try std.testing.expectEqualStrings("/permissions auto", nthSlashCompletion(testSlashRegistry(), "/permissions ", 1).?);
try std.testing.expectEqualStrings("/permissions remember", nthSlashCompletion(testSlashRegistry(), "/permissions ", 2).?);
try std.testing.expectEqualStrings("/permissions revoke", nthSlashCompletion(testSlashRegistry(), "/permissions ", 3).?);
try std.testing.expectEqualStrings("/permissions yolo", nthSlashCompletion(testSlashRegistry(), "/permissions ", 4).?);
try std.testing.expectEqualStrings("/permissions full-access", nthSlashCompletion(testSlashRegistry(), "/permissions ", 4).?);
try std.testing.expectEqualStrings("/permissions reset", nthSlashCompletion(testSlashRegistry(), "/permissions ", 5).?);
try std.testing.expectEqual(@as(usize, 2), slashCompletionCount(testSlashRegistry(), "/permissions a"));
try std.testing.expectEqual(@as(usize, 1), slashCompletionCount(testSlashRegistry(), "/permissions as"));
try std.testing.expectEqualStrings("/permissions ask", nthSlashCompletion(testSlashRegistry(), "/permissions as", 0).?);
try std.testing.expectEqual(@as(usize, 1), slashCompletionCount(testSlashRegistry(), "/permissions au"));
try std.testing.expectEqualStrings("/permissions auto", nthSlashCompletion(testSlashRegistry(), "/permissions au", 0).?);
try std.testing.expectEqual(@as(usize, 1), slashCompletionCount(testSlashRegistry(), "/permissions y"));
try std.testing.expectEqualStrings("/permissions yolo", nthSlashCompletion(testSlashRegistry(), "/permissions y", 0).?);
try std.testing.expectEqual(@as(usize, 1), slashCompletionCount(testSlashRegistry(), "/permissions f"));
try std.testing.expectEqualStrings("/permissions full-access", nthSlashCompletion(testSlashRegistry(), "/permissions f", 0).?);
try std.testing.expectEqual(@as(usize, 3), slashCompletionCount(testSlashRegistry(), "/permissions r"));
try std.testing.expectEqualStrings("/permissions remember", nthSlashCompletion(testSlashRegistry(), "/permissions r", 0).?);
try std.testing.expectEqualStrings("/permissions revoke", nthSlashCompletion(testSlashRegistry(), "/permissions r", 1).?);
@@ -2095,7 +2095,7 @@ test "slash completion labels strip argument prefixes" {
try std.testing.expectEqualStrings("auto", nthSlashCompletionLabel(testSlashRegistry(), "/permissions ", 1).?);
try std.testing.expectEqualStrings("remember", nthSlashCompletionLabel(testSlashRegistry(), "/permissions ", 2).?);
try std.testing.expectEqualStrings("revoke", nthSlashCompletionLabel(testSlashRegistry(), "/permissions ", 3).?);
try std.testing.expectEqualStrings("yolo", nthSlashCompletionLabel(testSlashRegistry(), "/permissions ", 4).?);
try std.testing.expectEqualStrings("full-access", nthSlashCompletionLabel(testSlashRegistry(), "/permissions ", 4).?);
try std.testing.expectEqualStrings("reset", nthSlashCompletionLabel(testSlashRegistry(), "/permissions ", 5).?);
try std.testing.expectEqualStrings("view", nthSlashCompletionLabel(testSlashRegistry(), "/allowlist ", 0).?);
try std.testing.expectEqualStrings("add", nthSlashCompletionLabel(testSlashRegistry(), "/allowlist ", 1).?);
+1 -1
View File
@@ -332,7 +332,7 @@ test "help menu places descriptions after the widest matching command" {
test "help menu keeps a four column gutter after command names" {
const alloc = std.testing.allocator;
const long_specs = [_]command_specs.SlashSpec{
.{ .kind = .permissions, .command = "/permissions", .help_entry = "/permissions [ask|auto|remember|revoke|yolo|reset]", .completion_description = "choose permission behavior", .presentation_category = .security },
.{ .kind = .permissions, .command = "/permissions", .help_entry = "/permissions [ask|auto|remember|revoke|full-access|reset]", .completion_description = "choose permission behavior", .presentation_category = .security },
};
const projection: render_input.HelpMenuProjection = .{
.active = true,
+6 -6
View File
@@ -1733,8 +1733,8 @@ test "compose hint row prioritizes red yolo warning with compact fallback" {
var input = InputRuntime{};
defer input.deinit(std.testing.allocator);
var ctx = testRenderContext(&input);
ctx.danger_status = "YOLO enabled: fx permission checks disabled";
ctx.danger_status_compact = "YOLO: unrestricted";
ctx.danger_status = "Full access enabled: fx permission checks disabled";
ctx.danger_status_compact = "Full access";
var full = try composeHintRow(std.testing.allocator, false, ctx, 80);
defer full.deinit(std.testing.allocator);
@@ -1750,15 +1750,15 @@ test "compose hint row prioritizes red yolo warning with compact fallback" {
var suppressed = try composeHintRow(std.testing.allocator, false, ctx, 80);
defer suppressed.deinit(std.testing.allocator);
try std.testing.expect(std.mem.find(u8, suppressed.items, "esc again to clear") != null);
try std.testing.expect(std.mem.find(u8, suppressed.items, "YOLO") == null);
try std.testing.expect(std.mem.find(u8, suppressed.items, "Full access") == null);
}
test "compose hint row yields the yolo warning to a pending ctrl+c quit hint" {
var input = InputRuntime{};
defer input.deinit(std.testing.allocator);
var ctx = testRenderContext(&input);
ctx.danger_status = "YOLO enabled: fx permission checks disabled";
ctx.danger_status_compact = "YOLO: unrestricted";
ctx.danger_status = "Full access enabled: fx permission checks disabled";
ctx.danger_status_compact = "Full access";
ctx.ctrl_c_pending = true;
// Reported as unpainted so the warning's visible budget pauses.
@@ -1767,7 +1767,7 @@ test "compose hint row yields the yolo warning to a pending ctrl+c quit hint" {
var pending = try composeHintRow(std.testing.allocator, false, ctx, 60);
defer pending.deinit(std.testing.allocator);
try std.testing.expect(std.mem.find(u8, pending.items, "press ctrl+c again to exit") != null);
try std.testing.expect(std.mem.find(u8, pending.items, "YOLO") == null);
try std.testing.expect(std.mem.find(u8, pending.items, "Full access") == null);
ctx.ctrl_c_pending = false;
try std.testing.expectEqualStrings(ctx.danger_status, dangerStatusText(false, ctx, 60));
+1 -1
View File
@@ -1516,7 +1516,7 @@ const picker_test_slash_specs = [_]command_specs.SlashSpec{
.{ .kind = .clear_screen, .command = "/clear", .help_entry = "/clear", .completion_description = "clear the terminal transcript", .presentation_category = .general },
.{ .kind = .model, .command = "/model", .help_entry = "/model <id-or-query>", .completion_description = "choose what model and reasoning effort to use", .presentation_category = .model, .has_args = true },
.{ .kind = .mcp, .command = "/mcp", .help_entry = "/mcp [list|resource|prompt|add|remove]", .completion_description = "manage MCP servers, resources, and prompts", .presentation_category = .extensions, .has_args = true },
.{ .kind = .permissions, .command = "/permissions", .help_entry = "/permissions [ask|auto|remember|revoke|yolo|reset]", .completion_description = "choose permission behavior", .presentation_category = .security, .has_args = true },
.{ .kind = .permissions, .command = "/permissions", .help_entry = "/permissions [ask|auto|remember|revoke|full-access|reset]", .completion_description = "choose permission behavior", .presentation_category = .security, .has_args = true },
.{ .kind = .credits, .command = "/credits", .aliases = &.{"/balance"}, .help_entry = "/credits (/balance)", .completion_description = "show gateway credits balance", .presentation_category = .account },
.{ .kind = .settings, .command = "/settings", .help_entry = "/settings", .completion_description = "configure fx", .presentation_category = .general },
};
+3 -3
View File
@@ -246,7 +246,7 @@ fn permissionModeStatusLabel(mode: types.PermissionMode, out: []u8) []const u8 {
return switch (mode) {
.ask => "ask",
.auto => std.fmt.bufPrint(out, "{s}auto{s}", .{ permission_auto_style, statusline_style }) catch "auto",
.yolo => std.fmt.bufPrint(out, "{s}YOLO{s}", .{ permission_auto_style, statusline_style }) catch "YOLO",
.yolo => std.fmt.bufPrint(out, "{s}full access{s}", .{ permission_auto_style, statusline_style }) catch "full access",
};
}
@@ -1107,13 +1107,13 @@ test "buildHintLine colors auto mode with theme accent" {
try std.testing.expectEqualStrings(light_expected, light_line);
}
test "buildHintLine renders yolo uppercase with subdued permission styling" {
test "buildHintLine renders full access with subdued permission styling" {
initTheme(false, null);
var buf: [128]u8 = undefined;
const line = buildHintLine(false, true, "openai/gpt-4o", .yolo, false, .auto, false, .{}, 80, &buf);
const expected = try std.fmt.allocPrint(
std.testing.allocator,
"{s}YOLO{s} · gpt-4o",
"{s}full access{s} · gpt-4o",
.{ permission_auto_style, statusline_style },
);
defer std.testing.allocator.free(expected);
+4 -3
View File
@@ -382,11 +382,12 @@ describe("cli: help", () => {
Run one noninteractive request
Usage:
fx ask [--auto|--yolo] [--image PATH] [--system TEXT] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--continue-recovery] [--] <prompt>
fx ask [--auto|--full-access] [--image PATH] [--system TEXT] [--json] [--quiet] [--prompt-permissions] [--no-save] [--no-color] [--resume <last|id>|--resume-id <id>] [--continue-recovery] [--] <prompt>
Options:
--auto Automatically review unresolved permission requests
--yolo Disable fx permission checks
--full-access Disable fx permission checks
--yolo Alias for --full-access
--image PATH Attach an image file; repeat for multiple images
--system TEXT Replace the built-in system prompt for this request
--json Emit machine-readable JSON instead of text
@@ -4740,7 +4741,7 @@ describe("cli: error handling", () => {
"fx ask: --no-save cannot be used with --resume or --resume-id",
);
expect(rejected.stderr).toContain(
"usage: fx ask [--auto|--yolo] [--image PATH] [--system TEXT] [--json] [--quiet] [--prompt-permissions] [--no-save]",
"usage: fx ask [--auto|--full-access] [--image PATH] [--system TEXT] [--json] [--quiet] [--prompt-permissions] [--no-save]",
);
}
expect(gateway.requests).toHaveLength(0);
+1 -1
View File
@@ -123,7 +123,7 @@ describe("external file permissions", () => {
);
expect(result.stderr).toContain(
"YOLO enabled: fx permission checks disabled",
"Full access enabled: fx permission checks disabled",
);
const output = parseFxJson(result);
expect(
+1 -1
View File
@@ -3,7 +3,7 @@ import { expect } from "bun:test";
const permissionModeContext = {
ask: "Runtime context: permission mode is ask. Sensitive tool calls may require user approval unless configured rules or session grants already decide them. Tool admission remains authoritative.",
auto: "Runtime context: permission mode is auto. After configured rules, session grants, and deterministic safe-tool authority, fx sends each unresolved action to a narrow safety reviewer. A clear result authorizes only that exact action. A caution or unavailable result holds only that action and returns advice without opening a permission screen, disabling tools, or ending the turn. Exact cautions are reused for this turn; choose a materially different safe action or explain why no safe path remains. Tool admission and exact live revalidation remain authoritative.",
yolo: "Runtime context: permission mode is yolo. fx permission policy is disabled. Tool lookup, argument validation, execution authority, cancellation, limits, operating-system permissions, and remote authentication remain authoritative.",
yolo: "Runtime context: permission mode is full access. fx permission policy is disabled. Tool lookup, argument validation, execution authority, cancellation, limits, operating-system permissions, and remote authentication remain authoritative.",
} as const;
export function expectPermissionModeContext(
+3 -3
View File
@@ -805,7 +805,7 @@ function normalizeVolatileStatusRows(grid: string[]): string[] {
/^• Streaming \([^)]*\)$/.test(line) ||
isVolatileTokenStatusRow(line)
? "<status>"
: line.replace(/\s+YOLO enabled: fx permission checks disabled$/, "")
: line.replace(/\s+Full access enabled: fx permission checks disabled$/, "")
);
}
@@ -813,8 +813,8 @@ test("volatile token status rows normalize before transcript grid comparison", (
expect(normalizeVolatileStatusRows([" (↑10 ↓5)"])).toEqual(["<status>"]);
expect(normalizeVolatileStatusRows([" 0s (↑10 ↓5)"])).toEqual(["<status>"]);
expect(normalizeVolatileStatusRows([
"YOLO · gpt-5 YOLO enabled: fx permission checks disabled",
])).toEqual(["YOLO · gpt-5"]);
"full access · gpt-5 Full access enabled: fx permission checks disabled",
])).toEqual(["full access · gpt-5"]);
});
describe("effect-aware command permissions", () => {
+2 -2
View File
@@ -98,11 +98,11 @@ describe.skipIf(TMUX_SKIP)("tui: no-key slash commands", () => {
session = launched.terminal;
await session.sendText("/permissions");
await session.waitForText("usage: /permissions [ask|auto|yolo|reset]", 5_000);
await session.waitForText("usage: /permissions [ask|auto|full-access|reset]", 5_000);
const scrollback = await session.captureFullScrollback();
const statusIndex = scrollback.search(/● Permissions: mode=(?:ask|auto)/);
const usageIndex = scrollback.indexOf(
"usage: /permissions [ask|auto|yolo|reset]",
"usage: /permissions [ask|auto|full-access|reset]",
);
expect(statusIndex).toBeGreaterThanOrEqual(0);
expect(usageIndex).toBeGreaterThan(statusIndex);
+72 -9
View File
@@ -21,8 +21,8 @@ import {
} from "./tmux-helpers";
import { expectPermissionModeContext } from "./permission-mode-context";
const WARNING = "YOLO enabled: fx permission checks disabled";
const COMPACT_WARNING = "YOLO: unrestricted";
const WARNING = "Full access enabled: fx permission checks disabled";
const COMPACT_WARNING = "Full access";
const QUIT_HINT = "press ctrl+c again to exit";
const COMMAND_APPROVAL_PROMPT = "Would you like to run the following command?";
const TIMEOUT = 30_000;
@@ -74,9 +74,9 @@ async function waitForGatewayRequestCount(
}
describe("yolo permission mode", () => {
test(
"headless mode warns once, bypasses configured denial, and keeps stdout clean",
async () => {
test.each(["--full-access", "--yolo"])(
"%s warns once, bypasses configured denial, and keeps stdout clean",
async (flag) => {
const fixture = createFixture("fx-yolo-headless-");
const markerPath = join(fixture.workspace, "yolo-command.txt");
const tracePath = join(fixture.root, "permission-trace.log");
@@ -101,7 +101,7 @@ describe("yolo permission mode", () => {
gateway = fake;
const result = await runFx(
["ask", "--yolo", "--json", "--no-save", "Run the fixture command exactly once."],
["ask", flag, "--json", "--no-save", "Run the fixture command exactly once."],
{
cwd: fixture.workspace,
env: {
@@ -150,6 +150,58 @@ describe("yolo permission mode", () => {
TIMEOUT,
);
test.each(["full-access", "full access", "yolo"])(
"status accepts %s in settings and environment without changing JSON",
async (mode) => {
const fixture = createFixture("fx-full-access-status-");
writeFileSync(fixture.settingsPath, JSON.stringify({ permission_mode: mode }));
const env = {
HOME: fixture.home,
FX_PERMISSION_MODE: undefined,
FX_AUTO_UPGRADE: "0",
};
const configured = await runFx(["status", "--json"], { cwd: fixture.workspace, env });
expect(configured.code).toBe(0);
expect(configured.stderr).toBe("");
expect(JSON.parse(configured.stdout).permission_mode).toBe("yolo");
const fake = startFakeGateway([fakeGatewayFinalText("ALIAS_WRITE_DONE")]);
gateway = fake;
const request = await runFx(["ask", "--no-save", "--json", "Reply once."], {
cwd: fixture.workspace,
env: {
...env,
AI_GATEWAY_API_KEY: "fake-alias-key",
FX_GATEWAY_BASE_URL: fake.baseUrl,
FX_GATEWAY_CHAT_URL: fake.chatUrl,
FX_MODEL: FAKE_GATEWAY_MODEL,
},
timeoutMs: TIMEOUT,
});
expect(request.code).toBe(0);
expect(request.stderr).toBe(`${WARNING}\n`);
expect(JSON.parse(request.stdout).output).toContain("ALIAS_WRITE_DONE");
expect(JSON.parse(readFileSync(fixture.settingsPath, "utf8"))).toMatchObject({
permission_mode: mode,
yolo_acknowledged: true,
});
writeFileSync(fixture.settingsPath, JSON.stringify({ permission_mode: "ask" }));
const overridden = await runFx(["status", "--json"], {
cwd: fixture.workspace,
env: { ...env, FX_PERMISSION_MODE: mode },
});
expect(overridden.code).toBe(0);
expect(JSON.parse(overridden.stdout).permission_mode).toBe("yolo");
const text = await runFx(["permissions"], {
cwd: fixture.workspace,
env: { ...env, FX_PERMISSION_MODE: mode },
});
expect(text.code).toBe(0);
expect(text.stdout).toContain("mode=full access");
expect(JSON.parse(readFileSync(fixture.settingsPath, "utf8")).permission_mode).toBe("ask");
},
TIMEOUT,
);
test(
"status omits sandbox while preserving the legacy configured value",
async () => {
@@ -271,7 +323,7 @@ describe.skipIf(!tmuxAvailable())("yolo interactive mode", () => {
await session.waitForText("auto ·", TIMEOUT);
await session.sendKeys("BTab");
const warningPane = await session.waitForText(WARNING, TIMEOUT);
expect(warningPane).toContain("YOLO ·");
expect(warningPane).toContain("full access ·");
await session.sendText("/settings");
const settingsPane = await session.waitForText("←→ Change", TIMEOUT);
@@ -282,7 +334,7 @@ describe.skipIf(!tmuxAvailable())("yolo interactive mode", () => {
await session.sendKeys("Escape");
const resumedWarning = await session.waitForText(WARNING, TIMEOUT);
expect(resumedWarning).toContain("YOLO ·");
expect(resumedWarning).toContain("full access ·");
await Bun.sleep(1_500);
expect(await session.capturePane()).toContain(WARNING);
@@ -301,6 +353,17 @@ describe.skipIf(!tmuxAvailable())("yolo interactive mode", () => {
const statusPane = await session.waitForText("agent_step_limit=", TIMEOUT);
expect(statusPane).toContain("permission_mode=ask");
expect(statusPane).not.toContain("sandbox=");
const scrollback = await session.captureFullScrollback();
expect(scrollback).not.toContain("YOLO ·");
expect(scrollback).not.toContain("YOLO enabled:");
for (const mode of ["full-access", "yolo", "full access"]) {
await session.sendText(`/permissions ${mode}`);
await session.waitForText("mode set to full access", TIMEOUT);
await session.waitForText("full access ·", TIMEOUT);
expect(JSON.parse(readFileSync(fixture.settingsPath, "utf8")).permission_mode).toBe("yolo");
await session.sendKeys("BTab");
await session.waitForText("ask ·", TIMEOUT);
}
expect(readFileSync(stderrPath, "utf8")).toBe("");
},
45_000,
@@ -442,7 +505,7 @@ describe.skipIf(!tmuxAvailable())("yolo interactive mode", () => {
await session.sendText("Run the requested marker command.");
await waitForGatewayRequestCount(fake, 1);
await session.sendKeys("BTab");
await session.waitForText("YOLO ·", TIMEOUT);
await session.waitForText("full access ·", TIMEOUT);
await session.sendKeys("BTab");
await session.waitForText("ask ·", TIMEOUT);
releaseToolCall?.();