Files
Kigi-CLI/crates/codegen/kigi-workspace/src/permission/prompter.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1640 lines
70 KiB
Rust

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<acp::PermissionOptionId, acp::PermissionOption>,
) -> IndexMap<acp::PermissionOptionId, acp::PermissionOption> {
if !client_supports_enable_always_approve(client_type) {
return base;
}
let mut with_yolo: IndexMap<acp::PermissionOptionId, acp::PermissionOption> = 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<String>,
}
/// Delimiter used to qualify MCP tool names as `"<server>__<tool>"`.
/// 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 `"<server_prefix>__"`.
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::<String>(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.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<String>,
}
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 { 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<acp::PermissionOptionId, acp::PermissionOption>,
bash_options: IndexMap<acp::PermissionOptionId, acp::PermissionOption>,
/// Generic bash options for non-TUI clients - shows complete command with approve/reject always
generic_bash_options: IndexMap<acp::PermissionOptionId, acp::PermissionOption>,
fallback_options: IndexMap<acp::PermissionOptionId, acp::PermissionOption>,
/// 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<acp::PermissionOptionId, acp::PermissionOption> =
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<acp::PermissionOptionId, acp::PermissionOption> =
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<acp::PermissionOptionId, acp::PermissionOption> =
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<acp::PermissionOptionId, acp::PermissionOption> =
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<acp::PermissionOptionId, acp::PermissionOption> {
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<acp::Meta> {
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<acp::PermissionOptionId, acp::PermissionOption> {
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<acp::PermissionOptionId, acp::PermissionOption> =
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<acp::PermissionOptionId, acp::PermissionOption> =
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<String>,
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<acp::PermissionOptionId, acp::PermissionOption>,
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::<McpScopeSelection>(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::<BashCommandSelectedTerms>(
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::<BashCommandSelectedTerms>(
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<acp::PermissionOptionId, acp::PermissionOption>,
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<acp::PermissionOptionId, acp::PermissionOption>,
option_id: &str,
meta: Option<acp::Meta>,
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<serde_json::Value> = 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(_)));
}
}