use indexmap::IndexMap; use std::sync::Arc; use std::time::Instant; use crate::permission::{ bash_command_splitting::{BashCommandHighlights, primary_command_from_script}, types::{AccessKind, ClientType}, }; use agent_client_protocol::{self as acp, Client as _}; use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender; use kigi_file_utils::events::{Event, EventWriter, PermissionDecision}; use kigi_tools::implementations::kigi::web_fetch::domain_from_url; const REJECT_ONCE_LABEL: &str = "No, and tell Kigi what to do differently"; /// Stable option id for the edit prompt's "Yes, allow all edits during this /// session" choice. Distinct from the generic `"always-allow"` id (used by /// `fallback_options` / `generic_bash_options` with genuinely-persistent /// semantics) so that [`map_selected_outcome`] can map it to the /// session-scoped [`PromptOutcome::AllowEditsForSession`] without coupling on /// the access kind. Session edit allows are in-memory only and never persisted. /// /// Exposed so the pager can recognise this option (it is edit-scoped, so it /// must not be recorded as a sticky cursor target — see `permission_cursor`). pub const ALLOW_EDITS_SESSION_OPTION_ID: &str = "allow-edits-session"; /// Stable option id for the "enable always-approve mode" option that is /// prepended to every permission prompt for TUI / Pager / Desktop clients. /// /// Semantics (split between shell and client by design): /// /// - **Shell-side**: [`map_selected_outcome`] returns [`PromptOutcome::AllowOnce`] /// when this id is selected. The shell does NOT perform any per-tool /// whitelisting — the in-flight request is allowed exactly once, like /// pressing "Yes". The shell never persists anything based on this id. /// /// - **Client-side** (pager): when the user picks this option, the pager /// ALSO fires its existing `set_yolo_mode(true)` flow, which: /// 1. Flips local YOLO state on the active agent /// 2. Drains any queued permission requests with `AllowOnce` responses /// 3. Persists `[ui] permission_mode = "always-approve"` to /// `~/.kigi/config.toml` via the `Effect::PersistPermissionMode` effect /// 4. Sends the existing `kigi/yolo_mode_changed` ACP notification so /// the agent's permission manager flips its `yolo_mode` flag /// /// This split keeps the wire protocol bog-standard ACP (no new methods or /// extensions, no new `PermissionOptionKind` variant) while still giving /// the user a single click to turn on always-approve mode. /// /// The option is wire-compatible: clients that don't recognise the id /// (e.g. older pager builds, third-party ACP clients) treat it as an /// ordinary `AllowAlways` option and the shell still maps the response /// to `AllowOnce`. Worst case: the user grants the current call but the /// session-wide toggle is not applied. They can still flip it via /// `/always-approve`, Ctrl+O, or the settings modal. pub const ENABLE_ALWAYS_APPROVE_OPTION_ID: &str = "enable-always-approve"; /// User-facing label for the "enable always-approve mode" option. Kept /// here (not at each construction site) so the label is identical across /// every permission prompt — edit, bash, MCP, web_fetch, fallback. const ENABLE_ALWAYS_APPROVE_LABEL: &str = "Yes, and don't ask again for anything (always-approve mode)"; /// Build the "enable always-approve mode" option that is prepended to /// every TUI/Pager/Desktop permission prompt. See /// [`ENABLE_ALWAYS_APPROVE_OPTION_ID`] for the wire-level semantics. /// /// `kind` is `AllowOnce` (not `AllowAlways`) so that: /// /// - The pager's YOLO auto-approve drain (`handle_permission_request` /// and `set_yolo_mode_inner`) seeks the first `AllowOnce` and will pick /// this option. That is safe: those code paths bypass /// `dispatch_permission_select` and send the response directly via /// the oneshot, so the `set_yolo_mode(true)` side effect does NOT /// re-fire on auto-approval. The shell still maps the id to /// `PromptOutcome::AllowOnce` and the action is allowed exactly once. /// /// Note: the pager's `default_selected_permission` + sticky "last used" /// cursor logic (see `DefaultSelectedPermission` + `enqueue_permission`) /// deliberately skips this option via `is_enable_always_approve_option` /// when a configured or last-used preselection is in play. When neither is /// set, the cursor preselects THIS option explicitly (also via /// `is_enable_always_approve_option`, not by index 0). /// /// The shell-side `map_selected_outcome` returns /// `PromptOutcome::AllowOnce` for this id under the `AllowOnce` kind /// branch directly; the `AllowAlways` override is kept as a defensive /// guard for older / third-party clients that may have observed an /// earlier build where the kind was `AllowAlways`. fn enable_always_approve_option() -> acp::PermissionOption { acp::PermissionOption::new( ENABLE_ALWAYS_APPROVE_OPTION_ID, ENABLE_ALWAYS_APPROVE_LABEL.to_owned(), acp::PermissionOptionKind::AllowOnce, ) } /// Returns whether the given option is the special "enable always-approve mode" /// (global yolo) option that is prepended for KigiTUI / KigiPager / Desktop. /// /// This is the canonical way to identify the option instead of matching on /// its human-facing label or assuming position 0. Callers that need to /// treat this option specially for default-cursor logic, YOLO draining, etc. /// should use this helper. pub fn is_enable_always_approve_option(opt: &acp::PermissionOption) -> bool { opt.option_id.0.as_ref() == ENABLE_ALWAYS_APPROVE_OPTION_ID } /// Returns `true` if the given client type should see the prepended /// "enable always-approve mode" option. Limited to the three clients /// (`KigiTUI`, `KigiPager`, `Desktop`) that wire the option id through /// to their YOLO toggle. Other clients keep their existing option set. fn client_supports_enable_always_approve(client_type: ClientType) -> bool { matches!( client_type, ClientType::KigiTUI | ClientType::KigiPager | ClientType::Desktop ) } /// Wrap the per-access-kind option map with the "enable always-approve /// mode" option prepended as position 0 — but only for client types /// that know how to act on it. Called by [`AcpPrompter::build_options`] /// at the tail of every branch so the new option lands first regardless /// of which base map (edit / bash / mcp / fallback) was used. fn prepend_enable_always_approve( client_type: ClientType, base: IndexMap, ) -> IndexMap { if !client_supports_enable_always_approve(client_type) { return base; } let mut with_yolo: IndexMap = IndexMap::new(); let opt = enable_always_approve_option(); with_yolo.insert(opt.option_id.clone(), opt); // `IndexMap::extend` preserves order. A duplicate id in `base` would // overwrite our entry while keeping our position — but the constants // chosen here (`"always-allow"`, `"allow-edits-session"`, `"allow-once"`, // `"reject-once"`, `"allow-always-mcp"`, `"allow-always-domain"`, // `"allow-always-command"`, `"reject-always-command"`, `"reject-always"`) // are all distinct from `ENABLE_ALWAYS_APPROVE_OPTION_ID`, so there is no // collision in practice. with_yolo.extend(base); with_yolo } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BashCommandPermission { pub prompt_prefix: String, } /// Contains the terms of the command which were selected by the user, if more terms /// were selected they are also shown here and the selection is independent of the /// outcome of this selection itself #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct BashCommandSelectedTerms { pub command_parts: Vec, } /// Delimiter used to qualify MCP tool names as `"__"`. /// Canonical definition lives in `kigi_workspace_types` (so both the /// permission-validation layer and the MCP transport in `kigi-mcp` can /// depend on it without dragging the full workspace or rmcp into each /// other). Re-exported here for backward-compat with callers that historically /// reached `kigi_workspace::permission::MCP_TOOL_NAME_DELIMITER`. /// Validation in `into_registration` rejects MCP tools whose qualified name /// contains more than one occurrence of this delimiter, so stripping it given /// a trusted `server_prefix` is always unambiguous. pub use kigi_workspace_types::MCP_TOOL_NAME_DELIMITER; /// Extract the action segment of a qualified MCP tool name using a /// trusted `server_prefix`. Returns the full `tool_name` when there is /// no server prefix; when there is, debug builds assert the invariant /// that `tool_name` starts with `"__"`. pub fn mcp_tool_action<'a>(tool_name: &'a str, server_prefix: Option<&str>) -> &'a str { let Some(prefix) = server_prefix else { return tool_name; }; let action = tool_name .strip_prefix(prefix) .and_then(|rest| rest.strip_prefix(MCP_TOOL_NAME_DELIMITER)); debug_assert!( action.is_some(), "MCP tool name invariant: '{tool_name}' should start with '{prefix}{MCP_TOOL_NAME_DELIMITER}'" ); action.unwrap_or(tool_name) } /// Pretty-format a single MCP server- or tool-name segment for display: /// split on `'_'`, title-case each word, join with spaces. Leaves /// non-underscore characters (camelCase, hyphens) intact, so /// `"list_issues"` → `"List Issues"`, `"kigi_com_notion"` → /// `"Kigi Com Notion"`, and `"getMyTaskList"` → `"GetMyTaskList"`. pub fn mcp_titleize_segment(name: &str) -> String { name.split('_') .map(|word| { let mut chars = word.chars(); match chars.next() { Some(first) => first.to_uppercase().chain(chars).collect::(), None => String::new(), } }) .collect::>() .join(" ") } /// User-facing tool label, e.g. `"(Linear) List Issues"`. Falls back /// to the title-cased `tool_name` when there is no server prefix. /// The `"(Server) Action"` form visually distinguishes the /// owning server without relying on color (some surfaces are monochrome /// or already use color for other state). pub fn mcp_tool_display_name(tool_name: &str, server_prefix: Option<&str>) -> String { let action = mcp_tool_action(tool_name, server_prefix); match server_prefix { Some(server) => format!( "({}) {}", mcp_titleize_segment(server), mcp_titleize_segment(action) ), None => mcp_titleize_segment(tool_name), } } /// Display variant for callers that have only a qualified-or-raw tool /// name string (e.g. activity titles from ACP `tool_call.fields.title` /// or scrollback blocks that store the wire name verbatim). Splits on /// the (validated-at-construction) `MCP_TOOL_NAME_DELIMITER`: if the /// split succeeds the name is formatted as `"(Server) Action"` with /// each segment title-cased; otherwise the input is returned unchanged /// (no title-casing — the input may be a bash command, file path, or /// other non-MCP text that the caller mustn't mangle). pub fn mcp_pretty_name_if_qualified(name: &str) -> String { match name.split_once(MCP_TOOL_NAME_DELIMITER) { Some((server, action)) => format!( "({}) {}", mcp_titleize_segment(server), mcp_titleize_segment(action) ), None => name.to_owned(), } } /// Meta attached to the "Always allow" option for an MCP tool prompt. /// Carries the full tool name and the server-prefix segment so the view /// can render the scope toggle without re-parsing the name. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct McpToolPermission { /// Static label prefix shown before the dynamic scope text, /// e.g. `"Always allow:"`. Mirrors `BashCommandPermission::prompt_prefix`. pub prompt_prefix: String, /// Full tool name as the agent called it /// (e.g. `"kigi_com_notion__notion-fetch"`). pub tool_name: String, /// Server segment (everything before the single `__` separator, /// e.g. `"kigi_com_notion"`). `None` if the tool name has no `__`, /// in which case the view hides the scope toggle and only offers /// tool-scope. pub server_prefix: Option, } impl McpToolPermission { /// Action segment of the qualified tool name. See [`mcp_tool_action`]. pub fn action(&self) -> &str { mcp_tool_action(&self.tool_name, self.server_prefix.as_deref()) } /// User-facing tool label. See [`mcp_tool_display_name`]. pub fn display_name(&self) -> String { mcp_tool_display_name(&self.tool_name, self.server_prefix.as_deref()) } } /// User's selected scope for an MCP "always allow" grant. Sent back from /// the view in `RequestPermissionResponse::meta` when the user picks the /// AllowAlways option for an MCP prompt. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum McpScopeSelection { /// Whitelist exactly this tool name. Tool { tool_name: String }, /// Whitelist every tool whose name starts with `__`. Server { server: String }, } #[derive(Debug)] pub enum PromptOutcome { AllowOnce, AllowAlways, /// Session-scoped: allow all edits for the remainder of this session only. /// Does **not** persist to disk (unlike the legacy `AllowAlways` path for edits). /// Matches the UX of "Yes, allow all edits during this session". AllowEditsForSession, AllowAlwaysBashCommand(String), AllowAlwaysDomain(String), /// Persist this exact MCP tool name in `allowed_mcp_tools`. AllowAlwaysMcpTool(String), /// Persist this MCP server prefix (no trailing `__`) in /// `allowed_mcp_servers`. An empty string is rejected by the manager. AllowAlwaysMcpServer(String), RejectOnce, RejectAlwaysBashCommand(String), Cancelled, // If the user provided a followup message instead of an action, the string here will // have it // TODO: Should the string here be prompt parts instead and should we allow @ and other // niceness on the input bar here? FollowupMessage(String), Error(String), } pub struct AcpPrompter { session_id: acp::SessionId, gateway: GatewaySender, client_type: ClientType, edit_options: IndexMap, bash_options: IndexMap, /// Generic bash options for non-TUI clients - shows complete command with approve/reject always generic_bash_options: IndexMap, fallback_options: IndexMap, /// Per-session `events.jsonl` writer. [`request`](Self::request) emits a /// `PermissionRequested` at prompt-start and a paired `PermissionResolved` /// at decision-time through it. `EventWriter::noop()` when events recording /// is disabled (the default for the permission scaffolding's own tests). event_writer: EventWriter, /// When `false` (default, fail-safe), the per-tool "Always allow …" options /// are stripped (see [`REMEMBER_TOOL_APPROVALS_GATED_IDS`]). remember_tool_approvals: bool, } /// Per-tool always-allow/always-reject option ids stripped when the gate is off. /// `allow-once`, `reject-once`, `enable-always-approve`, and `allow-edits-session` /// always remain. const REMEMBER_TOOL_APPROVALS_GATED_IDS: &[&str] = &[ "allow-always-command", "reject-always-command", "allow-always-mcp", "allow-always-domain", "always-allow", "reject-always", ]; /// Build a bash "don't ask again" row (allow or deny). Single home for the invariant /// that the static label prefix equals `BashCommandPermission.prompt_prefix` — the pager /// rebuilds the label as `"{prompt_prefix} {words}"`, so drift would flicker on first ←/→. fn bash_scope_option( id: &str, prefix: &str, kind: acp::PermissionOptionKind, primary: &BashCommandHighlights, ) -> (acp::PermissionOptionId, acp::PermissionOption) { ( acp::PermissionOptionId::new(id), acp::PermissionOption::new( acp::PermissionOptionId::new(id), format!("{prefix} {}", primary.highlighted_words.join(" ")), kind, ) .meta( serde_json::to_value(BashCommandPermission { prompt_prefix: prefix.to_owned(), }) .ok() .and_then(|v| v.as_object().cloned()), ), ) } impl AcpPrompter { pub fn new( session_id: acp::SessionId, gateway: GatewaySender, client_type: ClientType, ) -> Self { let mut edit_options: IndexMap = IndexMap::new(); edit_options.insert( acp::PermissionOptionId::new(ALLOW_EDITS_SESSION_OPTION_ID), acp::PermissionOption::new( ALLOW_EDITS_SESSION_OPTION_ID, "Yes, allow all edits during this session".to_owned(), acp::PermissionOptionKind::AllowAlways, ), ); edit_options.insert( acp::PermissionOptionId::new("allow-once"), acp::PermissionOption::new( "allow-once", "Yes".to_owned(), acp::PermissionOptionKind::AllowOnce, ), ); edit_options.insert( acp::PermissionOptionId::new("reject-once"), acp::PermissionOption::new( "reject-once", REJECT_ONCE_LABEL.to_owned(), acp::PermissionOptionKind::RejectOnce, ), ); // Bash options for KigiTUI - interactive selection with expandable/contractable terms let mut bash_options: IndexMap = IndexMap::new(); bash_options.insert( acp::PermissionOptionId::new("allow-once"), acp::PermissionOption::new( "allow-once", "Yes, proceed".to_owned(), acp::PermissionOptionKind::AllowOnce, ), ); bash_options.insert( acp::PermissionOptionId::new("reject-once"), acp::PermissionOption::new( "reject-once", REJECT_ONCE_LABEL.to_owned(), acp::PermissionOptionKind::RejectOnce, ), ); // Generic bash options for non-TUI clients (e.g., web) - shows complete command inline let mut generic_bash_options: IndexMap = IndexMap::new(); generic_bash_options.insert( acp::PermissionOptionId::new("always-allow"), acp::PermissionOption::new( "always-allow", "Yes, and don't ask again for bash commands".to_owned(), acp::PermissionOptionKind::AllowAlways, ), ); generic_bash_options.insert( acp::PermissionOptionId::new("allow-once"), acp::PermissionOption::new( "allow-once", "Yes, proceed".to_owned(), acp::PermissionOptionKind::AllowOnce, ), ); generic_bash_options.insert( acp::PermissionOptionId::new("reject-once"), acp::PermissionOption::new( "reject-once", REJECT_ONCE_LABEL.to_owned(), acp::PermissionOptionKind::RejectOnce, ), ); generic_bash_options.insert( acp::PermissionOptionId::new("reject-always"), acp::PermissionOption::new( "reject-always", "No, and don't run bash commands".to_owned(), acp::PermissionOptionKind::RejectAlways, ), ); let mut fallback_options: IndexMap = IndexMap::new(); fallback_options.insert( acp::PermissionOptionId::new("always-allow"), acp::PermissionOption::new( "always-allow", "always allow".to_owned(), acp::PermissionOptionKind::AllowAlways, ), ); fallback_options.insert( acp::PermissionOptionId::new("allow-once"), acp::PermissionOption::new( "allow-once", "allow once".to_owned(), acp::PermissionOptionKind::AllowOnce, ), ); fallback_options.insert( acp::PermissionOptionId::new("reject-once"), acp::PermissionOption::new( "reject-once", "reject once".to_owned(), acp::PermissionOptionKind::RejectOnce, ), ); Self { session_id, gateway, client_type, edit_options, bash_options, generic_bash_options, fallback_options, // Defaults to noop: in the live (shell) permission path the shell's // own `EventTracker` already emits Permission* events, so the prompter // must NOT double-emit. A workspace-server-side caller that owns the // per-session `events.jsonl` opts in via [`with_event_writer`]. event_writer: EventWriter::noop(), // Fail-safe default; opt in via `with_remember_tool_approvals`. remember_tool_approvals: false, } } /// Set whether the granular per-tool "Always allow …" options are shown. /// See [`AcpPrompter::remember_tool_approvals`]. pub fn with_remember_tool_approvals(mut self, enabled: bool) -> Self { self.remember_tool_approvals = enabled; self } /// Attach a per-session `events.jsonl` writer so [`request`](Self::request) /// records `PermissionRequested` / `PermissionResolved`. Used by the /// workspace-server permission path (which owns the session log); the shell /// path leaves the default noop in place to avoid double-emitting alongside /// its own `EventTracker`. pub fn with_event_writer(mut self, event_writer: EventWriter) -> Self { self.event_writer = event_writer; self } fn build_options( &self, access: &AccessKind, ) -> IndexMap { let mut base = self.build_options_inner(access); // Gate off: strip the granular always-allow rows (order-preserving). if !self.remember_tool_approvals { for id in REMEMBER_TOOL_APPROVALS_GATED_IDS { base.shift_remove(&acp::PermissionOptionId::new(*id)); } } // Prepend the "enable always-approve mode" option as position 0 // for client types that wire the option id through to their YOLO // toggle. See `ENABLE_ALWAYS_APPROVE_OPTION_ID` doc-comment for // the full client/shell split. prepend_enable_always_approve(self.client_type, base) } /// Bash meta driving the pager's ←/→ scope selection for the /// `allow-always-command` / `reject-always-command` rows. `Some` only for /// fancy-UI clients with the gate on — otherwise those rows are absent and /// the meta is a dangling scope hint. fn bash_selection_meta(&self, access: &AccessKind) -> Option { match access { AccessKind::Bash(bash_command) if self.remember_tool_approvals && matches!( self.client_type, ClientType::KigiTUI | ClientType::KigiPager | ClientType::Desktop ) => { serde_json::to_value(primary_command_from_script(bash_command)) .ok() .and_then(|v| v.as_object().cloned()) } _ => None, } } /// Build the per-access-kind option map WITHOUT the /// "enable always-approve mode" prepend. Kept as a separate inner /// fn so `build_options` can wrap the result with one prepend call /// rather than threading the prepend through every match arm. fn build_options_inner( &self, access: &AccessKind, ) -> IndexMap { match access { AccessKind::Edit(_) => self.edit_options.clone(), AccessKind::Bash(bash_command) => { // For KigiTUI clients, use the fancy interactive options with term selection // For generic clients (web, etc.), use simpler options that work without // special UI handling match self.client_type { ClientType::KigiTUI | ClientType::KigiPager | ClientType::Desktop => { let mut bash_commands: IndexMap< acp::PermissionOptionId, acp::PermissionOption, > = IndexMap::new(); // Ordering: the always-allow row leads for discoverability; the // persistent deny trails so it never sits between safe options. let primary_command = primary_command_from_script(bash_command); if let Some(primary_command) = &primary_command { let (id, option) = bash_scope_option( "allow-always-command", "Always allow:", acp::PermissionOptionKind::AllowAlways, primary_command, ); bash_commands.insert(id, option); } // Then the standard allow/reject options bash_commands.extend(self.bash_options.clone()); // Trailing persistent deny; ordering rationale above. if let Some(primary_command) = &primary_command { let (id, option) = bash_scope_option( "reject-always-command", "Never allow:", acp::PermissionOptionKind::RejectAlways, primary_command, ); bash_commands.insert(id, option); } bash_commands } ClientType::Generic | ClientType::KigiWeb | ClientType::Nebula | ClientType::Extension => { // For generic clients, use simpler options that display well // The command is shown via tool_call_update, so options don't need it inline self.generic_bash_options.clone() } } } AccessKind::WebFetch(url) => { // Unreachable in practice: the manager rejects unparseable URLs // before prompting. Fallback exists only as defensive code. let domain = domain_from_url(url).unwrap_or_else(|| "unknown domain".to_string()); let mut options: IndexMap = IndexMap::new(); options.insert( acp::PermissionOptionId::new("allow-always-domain"), acp::PermissionOption::new( "allow-always-domain", format!("Yes, always allow {domain} this session"), acp::PermissionOptionKind::AllowAlways, ), ); options.insert( acp::PermissionOptionId::new("allow-once"), acp::PermissionOption::new( "allow-once", "Yes, allow once".to_owned(), acp::PermissionOptionKind::AllowOnce, ), ); options.insert( acp::PermissionOptionId::new("reject-once"), acp::PermissionOption::new( "reject-once", REJECT_ONCE_LABEL.to_owned(), acp::PermissionOptionKind::RejectOnce, ), ); options } AccessKind::MCPTool { name: tool_name, .. } => { // Toggle-aware clients (pager + TUI + Desktop) get the // `allow-always-mcp` option carrying `McpToolPermission` // meta. Pager renders the scope toggle; TUI/Desktop submit // without `McpScopeSelection` meta and the response mapper // defaults to tool-scope. Fallback clients use the legacy // `fallback_options` (`always-allow`) and the manager's // plain `AllowAlways` arm persists tool-scope. match self.client_type { ClientType::KigiTUI | ClientType::KigiPager | ClientType::Desktop => { let mut options: IndexMap = IndexMap::new(); let server_prefix = tool_name.split_once("__").map(|(s, _)| s.to_owned()); options.insert( acp::PermissionOptionId::new("allow-always-mcp"), acp::PermissionOption::new( "allow-always-mcp", format!("Always allow: {}", tool_name), acp::PermissionOptionKind::AllowAlways, ) .meta( serde_json::to_value(McpToolPermission { prompt_prefix: "Always allow:".to_owned(), tool_name: tool_name.clone(), server_prefix, }) .ok() .and_then(|v| v.as_object().cloned()), ), ); options.insert( acp::PermissionOptionId::new("allow-once"), acp::PermissionOption::new( "allow-once", "Yes".to_owned(), acp::PermissionOptionKind::AllowOnce, ), ); options.insert( acp::PermissionOptionId::new("reject-once"), acp::PermissionOption::new( "reject-once", REJECT_ONCE_LABEL.to_owned(), acp::PermissionOptionKind::RejectOnce, ), ); options } ClientType::Generic | ClientType::KigiWeb | ClientType::Nebula | ClientType::Extension => self.fallback_options.clone(), } } _ => self.fallback_options.clone(), } } pub async fn request( &self, access: &AccessKind, tool_call_update: &acp::ToolCallUpdate, ) -> PromptOutcome { let tool_name = tool_name_for_access(access); // events.jsonl: `PermissionRequested` at prompt-start. The `Instant` // captured here is what makes the paired `PermissionResolved.wait_ms` // truthful — it measures the user-facing prompt, not earlier manager // bookkeeping. self.event_writer.emit(Event::PermissionRequested { tool_name: tool_name.clone(), }); let prompt_start = Instant::now(); let mut resolved_guard = ResolvedOnDrop { event_writer: &self.event_writer, tool_name: Some(tool_name), prompt_start, }; let outcome = { let permission_options = self.build_options(access); let req = acp::RequestPermissionRequest::new( self.session_id.clone(), tool_call_update.clone(), permission_options.values().cloned().collect(), ) .meta(self.bash_selection_meta(access)); match self.gateway.request_permission(req).await { Ok(resp) => match resp.outcome { acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled, acp::RequestPermissionOutcome::Selected(selected) => map_selected_outcome( &permission_options, &selected.option_id, resp.meta.as_ref(), access, ), // TODO(acp-0.10): `RequestPermissionOutcome` is #[non_exhaustive]. _ => PromptOutcome::Error("unknown permission outcome".to_owned()), }, Err(e) => { tracing::error!(?e, "failed to request permission"); PromptOutcome::Error("failed to request permission".to_owned()) } } }; // events.jsonl: `PermissionResolved` at decision-time, with the truthful // user-facing wait derived from the prompt-start `Instant` above. let tool_name = resolved_guard .tool_name .take() .expect("guard is armed until normal completion"); self.event_writer.emit(Event::PermissionResolved { tool_name, decision: permission_decision_for_outcome(&outcome), wait_ms: prompt_start.elapsed().as_millis() as u64, }); outcome } } struct ResolvedOnDrop<'a> { event_writer: &'a EventWriter, tool_name: Option, prompt_start: Instant, } impl Drop for ResolvedOnDrop<'_> { fn drop(&mut self) { if let Some(tool_name) = self.tool_name.take() { self.event_writer.emit(Event::PermissionResolved { tool_name, decision: PermissionDecision::Cancelled, wait_ms: self.prompt_start.elapsed().as_millis() as u64, }); } } } /// Tool name used for `events.jsonl` Permission* events AND for the /// `PermissionEvent.tool_name` telemetry field. Single source of truth: the /// permission manager calls this for the `tool_name` component of its /// `(tool_name, access_kind, access_detail)` derivation, so the two cannot /// drift. pub(crate) fn tool_name_for_access(access: &AccessKind) -> String { match access { AccessKind::Read(_) => "read_file".to_owned(), AccessKind::Grep { .. } => "grep".to_owned(), AccessKind::Edit(_) => "search_replace".to_owned(), AccessKind::Bash(_) => "run_terminal_command".to_owned(), AccessKind::MCPTool { name, .. } => format!("mcp:{name}"), AccessKind::WebFetch(_) => "web_fetch".to_owned(), AccessKind::WebSearch(_) => "web_search".to_owned(), } } /// Map a [`PromptOutcome`] to the `events.jsonl` [`PermissionDecision`]. One /// `match` so the allow/deny/cancel/followup mapping is the single source of /// truth and cannot drift across call sites. fn permission_decision_for_outcome(outcome: &PromptOutcome) -> PermissionDecision { match outcome { PromptOutcome::AllowOnce | PromptOutcome::AllowAlways | PromptOutcome::AllowEditsForSession | PromptOutcome::AllowAlwaysBashCommand(_) | PromptOutcome::AllowAlwaysDomain(_) | PromptOutcome::AllowAlwaysMcpTool(_) | PromptOutcome::AllowAlwaysMcpServer(_) => PermissionDecision::Allow, PromptOutcome::RejectOnce | PromptOutcome::RejectAlwaysBashCommand(_) | PromptOutcome::Error(_) => PermissionDecision::Deny, PromptOutcome::Cancelled => PermissionDecision::Cancelled, PromptOutcome::FollowupMessage(_) => PermissionDecision::Followup, } } fn map_selected_outcome( permission_options: &IndexMap, option_id: &acp::PermissionOptionId, meta: Option<&acp::Meta>, access: &AccessKind, ) -> PromptOutcome { permission_options .get(option_id) .map(|option| match option.kind { acp::PermissionOptionKind::AllowOnce => PromptOutcome::AllowOnce, acp::PermissionOptionKind::AllowAlways => { // Defensive guard: the "enable always-approve mode" // option is built with kind `AllowOnce` (so the // pager's default-focus picker lands on it). This // branch is dead code in current builds but kept as // a safety net for older / third-party clients that // might echo the id back under `AllowAlways` — the // shell still treats it as a single allow, NEVER as // a per-tool whitelist. The session-wide YOLO flip // is the client's job, not the shell's. if option_id.0.as_ref() == ENABLE_ALWAYS_APPROVE_OPTION_ID { PromptOutcome::AllowOnce } else if option_id.to_string() == "allow-always-mcp" { if let Some(selection) = meta.and_then(|m| { serde_json::from_value::(serde_json::Value::Object( m.clone(), )) .ok() }) { match selection { McpScopeSelection::Tool { tool_name } => { PromptOutcome::AllowAlwaysMcpTool(tool_name) } McpScopeSelection::Server { server } => { if server.is_empty() { if let AccessKind::MCPTool { name, .. } = access { PromptOutcome::AllowAlwaysMcpTool(name.clone()) } else { PromptOutcome::AllowAlways } } else { PromptOutcome::AllowAlwaysMcpServer(server) } } } } else if let AccessKind::MCPTool { name, .. } = access { // No scope meta. TUI / Desktop case: the renderer // shows the option but does not build the toggle // response. Default to tool-scope using the // access-kind name. PromptOutcome::AllowAlwaysMcpTool(name.clone()) } else { PromptOutcome::AllowAlways } } else if option_id.to_string() == "allow-always-domain" { if let AccessKind::WebFetch(url) = access && let Some(domain) = domain_from_url(url) && !domain.is_empty() { PromptOutcome::AllowAlwaysDomain(domain) } else { // Defensive: unreachable if manager rejects unparseable URLs. // Don't persist an empty domain — allow this single call only. PromptOutcome::AllowOnce } } else if option_id.to_string() == "allow-always-command" { if let Some(bash_selected_commands) = meta.and_then(|m| { serde_json::from_value::( serde_json::Value::Object(m.clone()), ) .ok() }) { PromptOutcome::AllowAlwaysBashCommand( bash_selected_commands.command_parts.join(" "), ) } else if let AccessKind::Bash(cmd) = access { // No interactive selection meta (e.g. desktop client). // Compute the primary command from the script. if let Some(primary) = primary_command_from_script(cmd) { PromptOutcome::AllowAlwaysBashCommand( primary.highlighted_words.join(" "), ) } else { PromptOutcome::AllowAlways } } else { PromptOutcome::AllowAlways } } else if option_id.0.as_ref() == ALLOW_EDITS_SESSION_OPTION_ID { // The edit prompt's "Yes, allow all edits during this session". // Treat as session-scoped only (in-memory). Do not persist. PromptOutcome::AllowEditsForSession } else { PromptOutcome::AllowAlways } } acp::PermissionOptionKind::RejectOnce => { // Check if there's a followup message in the meta if let Some(followup) = meta .and_then(|m| m.get("followup_message")) .and_then(|v| v.as_str()) && !followup.trim().is_empty() { return PromptOutcome::FollowupMessage(followup.to_string()); } PromptOutcome::RejectOnce } acp::PermissionOptionKind::RejectAlways => { if option_id.to_string() == "reject-always-command" { if let Some(bash_selected_commands) = meta.and_then(|m| { serde_json::from_value::( serde_json::Value::Object(m.clone()), ) .ok() }) { PromptOutcome::RejectAlwaysBashCommand( bash_selected_commands.command_parts.join(" "), ) } else if let AccessKind::Bash(cmd) = access { if let Some(primary) = primary_command_from_script(cmd) { PromptOutcome::RejectAlwaysBashCommand( primary.highlighted_words.join(" "), ) } else { PromptOutcome::RejectOnce } } else { PromptOutcome::RejectOnce } } else { PromptOutcome::RejectOnce } } // TODO(acp-0.10): `PermissionOptionKind` is #[non_exhaustive]. _ => PromptOutcome::Error("unknown permission option kind".to_owned()), }) .unwrap_or_else(|| PromptOutcome::Error("unknown permission option".to_owned())) } #[cfg(test)] mod tests { use super::*; use tokio::sync::mpsc; fn prompter(client_type: ClientType) -> AcpPrompter { // Existing tests assert the always-allow options are present; off-state // has dedicated `gate_*` tests. prompter_with_gate(client_type, true) } fn prompter_with_gate(client_type: ClientType, remember: bool) -> AcpPrompter { let (tx, _rx) = mpsc::unbounded_channel(); let gateway = GatewaySender::new(tx); AcpPrompter::new( acp::SessionId::new(Arc::from("test-session")), gateway, client_type, ) .with_remember_tool_approvals(remember) } fn has_option( opts: &IndexMap, id: &str, ) -> bool { opts.contains_key(&acp::PermissionOptionId::new(id)) } #[test] fn gate_off_strips_bash_always_allow_keeps_yes_no() { let p = prompter_with_gate(ClientType::KigiPager, false); let access = AccessKind::Bash("kubectl get pods".to_owned()); let opts = p.build_options(&access); assert!( !has_option(&opts, "allow-always-command"), "gate off must strip allow-always-command" ); assert!( !has_option(&opts, "reject-always-command"), "gate off must strip reject-always-command" ); assert!(has_option(&opts, "allow-once"), "Yes must remain"); assert!(has_option(&opts, "reject-once"), "No must remain"); assert!( has_option(&opts, ENABLE_ALWAYS_APPROVE_OPTION_ID), "global always-approve must remain" ); } #[test] fn gate_on_includes_bash_always_allow() { let p = prompter_with_gate(ClientType::KigiPager, true); let access = AccessKind::Bash("kubectl get pods".to_owned()); let opts = p.build_options(&access); assert!( has_option(&opts, "allow-always-command"), "gate on must include allow-always-command" ); assert!( has_option(&opts, "reject-always-command"), "gate on must include reject-always-command" ); } #[test] fn gate_off_strips_mcp_always_allow() { let p = prompter_with_gate(ClientType::KigiPager, false); let access = AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); assert!(!has_option(&opts, "allow-always-mcp")); assert!(has_option(&opts, "allow-once")); assert!(has_option(&opts, "reject-once")); } #[test] fn gate_off_strips_generic_bash_always_and_reject_always() { let p = prompter_with_gate(ClientType::KigiWeb, false); let access = AccessKind::Bash("kubectl get pods".to_owned()); let opts = p.build_options(&access); assert!(!has_option(&opts, "always-allow")); assert!(!has_option(&opts, "reject-always")); assert!(has_option(&opts, "allow-once")); assert!(has_option(&opts, "reject-once")); } #[test] fn gate_off_strips_web_fetch_always_allow_domain() { let p = prompter_with_gate(ClientType::KigiPager, false); let access = AccessKind::WebFetch("https://example.com/x".to_owned()); let opts = p.build_options(&access); assert!(!has_option(&opts, "allow-always-domain")); assert!(has_option(&opts, "allow-once")); assert!(has_option(&opts, "reject-once")); } #[test] fn bash_meta_present_only_when_gate_on_for_fancy_clients() { let access = AccessKind::Bash("kubectl get pods".to_owned()); // Gate on + fancy client → meta carries the parsed command parts. let on = prompter_with_gate(ClientType::KigiPager, true); let meta = on.bash_selection_meta(&access).expect("meta present"); assert!( serde_json::from_value::< crate::permission::bash_command_splitting::BashCommandHighlights, >(serde_json::Value::Object(meta)) .is_ok(), "meta must deserialize back into BashCommandHighlights" ); // Gate off → no meta (no allow-always-command row to scope). let off = prompter_with_gate(ClientType::KigiPager, false); assert!(off.bash_selection_meta(&access).is_none()); // Generic client never gets the fancy-UI meta, even with the gate on. let generic = prompter_with_gate(ClientType::KigiWeb, true); assert!(generic.bash_selection_meta(&access).is_none()); // Non-bash access never carries bash meta. assert!( on.bash_selection_meta(&AccessKind::Edit("a.rs".to_owned())) .is_none() ); } #[test] fn bash_reject_always_command_maps_selected_words() { let p = prompter(ClientType::KigiPager); let access = AccessKind::Bash("cargo test --workspace".to_owned()); let opts = p.build_options(&access); // Pager path: the ←/→ word-scope selection arrives as // BashCommandSelectedTerms meta and wins over the raw script. let meta = serde_json::to_value(BashCommandSelectedTerms { command_parts: vec!["cargo".to_owned(), "test".to_owned()], }) .unwrap() .as_object() .cloned() .unwrap(); let outcome = outcome_for(&opts, "reject-always-command", Some(meta), &access); assert!( matches!( outcome, PromptOutcome::RejectAlwaysBashCommand(ref w) if w == "cargo test" ), "selected words must map to RejectAlwaysBashCommand, got {outcome:?}" ); // No selection meta: fall back to the primary command from the script. let outcome = outcome_for(&opts, "reject-always-command", None, &access); assert!( matches!( outcome, PromptOutcome::RejectAlwaysBashCommand(ref w) if w == "cargo test --workspace" ), "no meta must fall back to the primary command, got {outcome:?}" ); } #[test] fn gate_off_keeps_edit_session_allow() { // The edit session allow is governed separately, not by this gate. let p = prompter_with_gate(ClientType::KigiPager, false); let access = AccessKind::Edit("src/main.rs".to_owned()); let opts = p.build_options(&access); assert!( has_option(&opts, ALLOW_EDITS_SESSION_OPTION_ID), "edit session allow must survive the gate" ); } fn outcome_for( options: &IndexMap, option_id: &str, meta: Option, access: &AccessKind, ) -> PromptOutcome { let id = acp::PermissionOptionId::new(option_id); super::map_selected_outcome(options, &id, meta.as_ref(), access) } #[test] fn mcp_prompt_includes_allow_always_with_meta() { let p = prompter(ClientType::KigiTUI); let access = AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); let opt = opts .get(&acp::PermissionOptionId::new("allow-always-mcp")) .expect("allow-always-mcp option missing"); let meta = opt.meta.clone().expect("meta missing"); let perm: McpToolPermission = serde_json::from_value(serde_json::Value::Object(meta)).unwrap(); assert_eq!(perm.tool_name, "linear__list"); assert_eq!(perm.server_prefix.as_deref(), Some("linear")); assert_eq!(perm.prompt_prefix, "Always allow:"); } #[test] fn mcp_prompt_no_separator_hides_server_scope() { let p = prompter(ClientType::KigiPager); let access = AccessKind::MCPTool { name: "standalone".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); let opt = opts .get(&acp::PermissionOptionId::new("allow-always-mcp")) .unwrap(); let perm: McpToolPermission = serde_json::from_value(serde_json::Value::Object(opt.meta.clone().unwrap())).unwrap(); assert_eq!(perm.tool_name, "standalone"); assert_eq!(perm.server_prefix, None); } #[test] fn mcp_response_tool_scope() { let p = prompter(ClientType::KigiPager); let access = AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); let meta = serde_json::json!({"kind": "tool", "tool_name": "linear__list"}) .as_object() .cloned() .unwrap(); let outcome = outcome_for(&opts, "allow-always-mcp", Some(meta), &access); assert!(matches!( outcome, PromptOutcome::AllowAlwaysMcpTool(ref n) if n == "linear__list" )); } #[test] fn mcp_response_server_scope() { let p = prompter(ClientType::KigiPager); let access = AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); let meta = serde_json::json!({"kind": "server", "server": "linear"}) .as_object() .cloned() .unwrap(); let outcome = outcome_for(&opts, "allow-always-mcp", Some(meta), &access); assert!(matches!( outcome, PromptOutcome::AllowAlwaysMcpServer(ref s) if s == "linear" )); } #[test] fn mcp_response_empty_server_falls_back_to_tool() { let p = prompter(ClientType::KigiPager); let access = AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); let meta = serde_json::json!({"kind": "server", "server": ""}) .as_object() .cloned() .unwrap(); let outcome = outcome_for(&opts, "allow-always-mcp", Some(meta), &access); assert!(matches!( outcome, PromptOutcome::AllowAlwaysMcpTool(ref n) if n == "linear__list" )); } #[test] fn mcp_response_no_meta_falls_back_to_tool() { // TUI / Desktop case: option id is `allow-always-mcp` but the renderer // does not build the toggle meta. The prompter must default to // tool-scope using the access-kind name. let p = prompter(ClientType::KigiTUI); let access = AccessKind::MCPTool { name: "notion__fetch".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); let outcome = outcome_for(&opts, "allow-always-mcp", None, &access); assert!(matches!( outcome, PromptOutcome::AllowAlwaysMcpTool(ref n) if n == "notion__fetch" )); } #[test] fn mcp_fallback_client_returns_plain_allow_always() { // non-TUI clients (Generic / KigiWeb / Extension / …) see `fallback_options`. The // legacy `"always-allow"` id maps to plain `PromptOutcome::AllowAlways`; // the manager arm persists tool-scope from there. let p = prompter(ClientType::Generic); let access = AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }; let opts = p.build_options(&access); assert!( opts.contains_key(&acp::PermissionOptionId::new("always-allow")), "fallback option set must contain legacy `always-allow` id" ); assert!( !opts.contains_key(&acp::PermissionOptionId::new("allow-always-mcp")), "fallback clients must NOT see the `allow-always-mcp` option" ); let outcome = outcome_for(&opts, "always-allow", None, &access); assert!(matches!(outcome, PromptOutcome::AllowAlways)); } #[test] #[should_panic(expected = "MCP tool name invariant")] fn mcp_tool_action_debug_asserts_when_invariant_violated() { // server_prefix is Some(X) but tool_name doesn't start with X -- // that's a construction bug. debug_assert! should fire in dev // builds (release builds fall back to returning tool_name as-is). let _ = mcp_tool_action("totally-different-name", Some("linear")); } #[test] fn mcp_pretty_name_if_qualified_distinguishes_qualified_from_raw() { // Qualified MCP name: format as "(Server) Action" with both // segments title-cased. assert_eq!( mcp_pretty_name_if_qualified("linear__list_issues"), "(Linear) List Issues" ); // Non-qualified input (e.g. a bash command, file path, or any // string without `__`) is returned UNCHANGED — must not // title-case or mangle non-MCP strings. assert_eq!(mcp_pretty_name_if_qualified("read_file"), "read_file"); assert_eq!(mcp_pretty_name_if_qualified("cargo test"), "cargo test"); assert_eq!(mcp_pretty_name_if_qualified(""), ""); } #[test] fn mcp_titleize_segment_handles_snake_camel_kebab() { // snake_case → words split + each title-cased assert_eq!(mcp_titleize_segment("list_issues"), "List Issues"); assert_eq!(mcp_titleize_segment("kigi_com_notion"), "Kigi Com Notion"); // single word: just capitalize first letter assert_eq!(mcp_titleize_segment("linear"), "Linear"); // camelCase preserved (no `_` to split on, only first letter touched) assert_eq!(mcp_titleize_segment("getMyTaskList"), "GetMyTaskList"); // kebab-case preserved (no `_` to split on) assert_eq!(mcp_titleize_segment("notion-fetch"), "Notion-fetch"); // empty input doesn't panic assert_eq!(mcp_titleize_segment(""), ""); } // ------------------------------------------------------------------ // "Enable always-approve mode" option (prepended for TUI/Pager/Desktop) // ------------------------------------------------------------------ fn enable_always_approve_id() -> acp::PermissionOptionId { acp::PermissionOptionId::new(ENABLE_ALWAYS_APPROVE_OPTION_ID) } /// The new option must be the FIRST entry of the option list for /// every TUI/Pager/Desktop access kind. Order matters because the /// option's `index + 1` keyboard shortcut and visual prominence /// hinge on position 0. A regression that moves it later silently /// makes the "always approve" affordance harder to discover — /// pin position 0 with an exhaustive enumeration. #[test] fn enable_always_approve_is_first_option_for_pager() { let p = prompter(ClientType::KigiPager); let cases: Vec<(&str, AccessKind)> = vec![ ("edit", AccessKind::Edit("write".to_owned())), ("bash", AccessKind::Bash("ls -la".to_owned())), ( "mcp", AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }, ), // WebFetch URL must parse (manager rejects bad URLs before // prompting, but the option list is still built defensively). ( "web_fetch", AccessKind::WebFetch("https://example.com/a".to_owned()), ), ]; for (label, access) in cases { let opts = p.build_options(&access); let first = opts .keys() .next() .unwrap_or_else(|| panic!("{label}: empty option list")); assert_eq!( first.0.as_ref(), ENABLE_ALWAYS_APPROVE_OPTION_ID, "{label}: enable-always-approve must be the first option", ); } } /// Same pin as above for `KigiTUI` and `Desktop` — both client types /// route the option id through to the YOLO toggle, so both must /// see it. A copy-paste regression that limits the prepend to one /// client only would be caught here. #[test] fn enable_always_approve_is_first_for_tui_and_desktop() { for ct in [ClientType::KigiTUI, ClientType::Desktop] { let p = prompter(ct); let opts = p.build_options(&AccessKind::Edit("write".to_owned())); assert_eq!( opts.keys().next().map(|k| k.0.as_ref()), Some(ENABLE_ALWAYS_APPROVE_OPTION_ID), "client {ct:?}: enable-always-approve must be position 0 for edits", ); } } /// non-TUI clients (Generic / web / Extension / …) clients do NOT recognise the /// option id, so the prompter must NOT show it to them. If we did, /// selecting it would just allow the current call without flipping /// any always-approve state — a confusing UX. Pin the omission. #[test] fn enable_always_approve_omitted_for_non_tui_clients() { for ct in [ ClientType::Generic, ClientType::KigiWeb, ClientType::Nebula, ClientType::Extension, ] { let p = prompter(ct); let opts = p.build_options(&AccessKind::Edit("write".to_owned())); assert!( !opts.contains_key(&enable_always_approve_id()), "client {ct:?}: must NOT see enable-always-approve", ); } } /// The option has `kind = AllowAlways` (for default-focus / YOLO /// drain safety) but `map_selected_outcome` must override that to /// `PromptOutcome::AllowOnce` — the shell never persists per-tool /// state for this id. Pin the override for every access kind. #[test] fn enable_always_approve_maps_to_allow_once_for_every_access_kind() { let p = prompter(ClientType::KigiPager); let cases: Vec<(&str, AccessKind)> = vec![ ("edit", AccessKind::Edit("write".to_owned())), ("bash", AccessKind::Bash("ls".to_owned())), ( "mcp", AccessKind::MCPTool { name: "linear__list".to_owned(), input: serde_json::Value::Null, }, ), ( "web_fetch", AccessKind::WebFetch("https://example.com/a".to_owned()), ), ]; for (label, access) in cases { let opts = p.build_options(&access); let outcome = outcome_for(&opts, ENABLE_ALWAYS_APPROVE_OPTION_ID, None, &access); assert!( matches!(outcome, PromptOutcome::AllowOnce), "{label}: enable-always-approve must map to AllowOnce, got {outcome:?}", ); } } /// The option carries `kind = AllowOnce` so the pager's YOLO /// auto-approve drain (which sends the first `AllowOnce` response) /// picks it safely. Note the pager's `default_selected_permission` + /// sticky cursor logic skips this option (see /// `is_enable_always_approve_option` + `enqueue_permission`) when a /// target kind is in play. Pin the kind regardless. #[test] fn enable_always_approve_uses_allow_once_kind() { let p = prompter(ClientType::KigiPager); let opts = p.build_options(&AccessKind::Edit("write".to_owned())); let opt = opts .get(&enable_always_approve_id()) .expect("enable-always-approve must be present for pager"); assert_eq!( opt.kind, acp::PermissionOptionKind::AllowOnce, "enable-always-approve kind must be AllowOnce so the pager's \ YOLO auto-approve drain (first AllowOnce) picks it safely", ); } /// Bash on TUI/Pager/Desktop builds a custom option set with /// `allow-always-command` at position 0 by default. After the /// prepend, the new option must STILL be first — i.e. the prepend /// runs AFTER the bash-specific assembly, not before. This pins /// the order: [enable-always-approve, allow-always-command, /// allow-once, reject-once, reject-always-command]. #[test] fn bash_option_order_toggle_first_reject_always_last() { let p = prompter(ClientType::KigiPager); // "ls" has a parseable primary command, so `allow-always-command` // will be inserted into the option list. let access = AccessKind::Bash("ls -la".to_owned()); let opts = p.build_options(&access); let ids: Vec<&str> = opts.keys().map(|k| k.0.as_ref()).collect(); assert_eq!( ids.first().copied(), Some(ENABLE_ALWAYS_APPROVE_OPTION_ID), "enable-always-approve must be position 0", ); assert_eq!( ids.get(1).copied(), Some("allow-always-command"), "allow-always-command must remain position 1 (right after \ enable-always-approve), preserving bash UX", ); assert_eq!( ids.last().copied(), Some("reject-always-command"), "reject-always-command must be LAST so the persistent deny \ never sits between safe options", ); } // ── events.jsonl emission ───────────────────────────────── #[test] fn tool_name_for_access_pins_canonical_names() { // This helper is the single source of truth shared with the permission // manager's telemetry; pin every variant so a rename can't slip through. assert_eq!(tool_name_for_access(&AccessKind::Read(None)), "read_file"); assert_eq!( tool_name_for_access(&AccessKind::Grep { path: None, glob: None }), "grep" ); assert_eq!( tool_name_for_access(&AccessKind::Edit("x".into())), "search_replace" ); assert_eq!( tool_name_for_access(&AccessKind::Bash("ls".into())), "run_terminal_command" ); assert_eq!( tool_name_for_access(&AccessKind::MCPTool { name: "linear__list".into(), input: serde_json::Value::Null, }), "mcp:linear__list" ); assert_eq!( tool_name_for_access(&AccessKind::WebFetch("https://x".into())), "web_fetch" ); assert_eq!( tool_name_for_access(&AccessKind::WebSearch("rust lang".into())), "web_search" ); } #[test] fn decision_mapping_covers_allow_deny_cancel_followup() { // `PermissionDecision` has no `PartialEq`, so assert via `matches!`. assert!(matches!( permission_decision_for_outcome(&PromptOutcome::AllowOnce), PermissionDecision::Allow )); assert!(matches!( permission_decision_for_outcome(&PromptOutcome::AllowAlwaysMcpServer("s".into())), PermissionDecision::Allow )); assert!(matches!( permission_decision_for_outcome(&PromptOutcome::RejectOnce), PermissionDecision::Deny )); assert!(matches!( permission_decision_for_outcome(&PromptOutcome::Error("boom".into())), PermissionDecision::Deny )); assert!(matches!( permission_decision_for_outcome(&PromptOutcome::Cancelled), PermissionDecision::Cancelled )); assert!(matches!( permission_decision_for_outcome(&PromptOutcome::FollowupMessage("hi".into())), PermissionDecision::Followup )); } /// `request()` must emit a `PermissionRequested` at prompt-start and a paired /// `PermissionResolved` at decision-time when an event writer is attached. /// A dropped-receiver gateway makes `request_permission` fail fast (channel /// closed → `PromptOutcome::Error`), which still exercises both emissions and /// the Error→Deny decision mapping. #[tokio::test] async fn request_emits_permission_requested_and_resolved() { use kigi_file_utils::events::EventWriter; let dir = tempfile::tempdir().unwrap(); let writer = EventWriter::open(dir.path()); let (tx, rx) = mpsc::unbounded_channel(); drop(rx); // channel closed → request_permission errors immediately let gateway = GatewaySender::new(tx); let prompter = AcpPrompter::new( acp::SessionId::new(Arc::from("sess-perm")), gateway, ClientType::Generic, ) .with_event_writer(writer); let access = AccessKind::Bash("rm -rf /tmp/x".to_owned()); let tool_call_update = acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from("tc-1")), acp::ToolCallUpdateFields::default(), ); let outcome = prompter.request(&access, &tool_call_update).await; assert!( matches!(outcome, PromptOutcome::Error(_)), "dropped gateway receiver should yield PromptOutcome::Error" ); let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap(); let lines: Vec = text .trim() .lines() .map(|l| serde_json::from_str(l).unwrap()) .collect(); assert_eq!( lines.len(), 2, "expected PermissionRequested + PermissionResolved" ); assert_eq!(lines[0]["type"], "permission_requested"); assert_eq!(lines[0]["tool_name"], "run_terminal_command"); assert_eq!(lines[1]["type"], "permission_resolved"); assert_eq!(lines[1]["tool_name"], "run_terminal_command"); assert_eq!(lines[1]["decision"], "deny"); assert!( lines[1]["wait_ms"].as_u64().is_some(), "PermissionResolved must carry wait_ms" ); } /// The default constructor leaves the event writer as `noop()` — the live /// shell path relies on this to avoid double-emitting alongside its own /// `EventTracker`. With a `noop` writer there is no backing file to observe, /// so the strongest assertion available is that `request()` still returns the /// correct `PromptOutcome` (here `Error`, from the dropped gateway receiver) /// without requiring a writer. The *positive* emission path is covered by /// `request_emits_permission_requested_and_resolved`. #[tokio::test] async fn request_with_default_noop_writer_returns_outcome() { let (tx, rx) = mpsc::unbounded_channel(); drop(rx); let gateway = GatewaySender::new(tx); let prompter = AcpPrompter::new( acp::SessionId::new(Arc::from("sess-perm")), gateway, ClientType::Generic, ); let access = AccessKind::Read(Some("/etc/hosts".to_owned())); let tool_call_update = acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from("tc-2")), acp::ToolCallUpdateFields::default(), ); let outcome = prompter.request(&access, &tool_call_update).await; assert!(matches!(outcome, PromptOutcome::Error(_))); } }