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.
1640 lines
70 KiB
Rust
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(_)));
|
|
}
|
|
}
|