Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -81,6 +81,15 @@ struct Args {
|
|||
/// instead of widening to the built-in default catalog.
|
||||
#[arg(long)]
|
||||
require_explicit_toolset: bool,
|
||||
/// Trust project-scoped LSP servers from `<repo>/.grok/lsp.json`.
|
||||
/// Defaults off; sandbox opts in only after workspace trust is established.
|
||||
#[arg(
|
||||
long,
|
||||
env = "GROK_WORKSPACE_PROJECT_LSP_TRUSTED",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::Set,
|
||||
)]
|
||||
project_lsp_trusted: bool,
|
||||
/// Confine `x.ai/fs/*` resolution to the workspace root (reject `..`,
|
||||
/// absolute-outside-root, symlink escapes). On by default: the standalone
|
||||
/// server always backs a remote-sandbox workspace, a real tenant boundary.
|
||||
|
|
@ -338,7 +347,6 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
let project_lsp_trusted = true;
|
||||
let preview_scrape_interval = status_config.preview_activity_scrape_interval;
|
||||
xai_grok_workspace::init_metrics();
|
||||
let ws_handle = xai_grok_workspace::handle::connect_local_workspace(
|
||||
|
|
@ -351,7 +359,7 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
|
|||
args.allow_insecure_ws,
|
||||
status_config,
|
||||
args.upload_queue_enabled,
|
||||
project_lsp_trusted,
|
||||
args.project_lsp_trusted,
|
||||
Some(diag_handle.clone()),
|
||||
args.require_explicit_toolset,
|
||||
args.confine_fs_to_workspace_root,
|
||||
|
|
@ -448,6 +456,15 @@ mod tests {
|
|||
assert!(args.capabilities);
|
||||
}
|
||||
#[test]
|
||||
fn project_lsp_trust_defaults_off_and_is_opt_in() {
|
||||
unsafe { std::env::remove_var("GROK_WORKSPACE_PROJECT_LSP_TRUSTED") };
|
||||
let args = Args::try_parse_from(["xai-workspace-server"]).unwrap();
|
||||
assert!(!args.project_lsp_trusted);
|
||||
let args = Args::try_parse_from(["xai-workspace-server", "--project-lsp-trusted", "true"])
|
||||
.unwrap();
|
||||
assert!(args.project_lsp_trusted);
|
||||
}
|
||||
#[test]
|
||||
fn capabilities_manifest_shape() {
|
||||
let value = serde_json::to_value(CAPABILITIES).unwrap();
|
||||
assert_eq!(value, serde_json::json!({ "diag" : true }));
|
||||
|
|
|
|||
|
|
@ -77,17 +77,6 @@ pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
|
|||
|
||||
files
|
||||
.into_iter()
|
||||
.map(|mut file| {
|
||||
// Strip rules-file YAML frontmatter so it does not leak as raw YAML (matches grok-build render).
|
||||
if file.file_path.contains("/.grok/rules/")
|
||||
|| file.file_path.contains("/.claude/rules/")
|
||||
{
|
||||
file.content = xai_grok_tools::implementations::skills::skill::extract_skill_body(
|
||||
&file.content,
|
||||
);
|
||||
}
|
||||
file
|
||||
})
|
||||
.filter_map(|file| match serde_json::to_value(&file) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
|
|
@ -362,9 +351,9 @@ mod tests {
|
|||
|
||||
// Discovery also scans the real `~/.grok`, so fixtures use test-unique names.
|
||||
#[tokio::test]
|
||||
async fn discover_agents_md_strips_rules_frontmatter() {
|
||||
async fn discover_agents_md_receives_normalized_rule_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rules_dir = tmp.path().join(".grok").join("rules");
|
||||
let rules_dir = tmp.path().join(".cursor").join("rules");
|
||||
fs::create_dir_all(&rules_dir).unwrap();
|
||||
fs::write(
|
||||
rules_dir.join("xyzzy-discover-agents-md-test.md"),
|
||||
|
|
@ -378,7 +367,7 @@ mod tests {
|
|||
.find(|f| {
|
||||
f["file_path"]
|
||||
.as_str()
|
||||
.is_some_and(|p| p.ends_with("/.grok/rules/xyzzy-discover-agents-md-test.md"))
|
||||
.is_some_and(|p| p.ends_with("/.cursor/rules/xyzzy-discover-agents-md-test.md"))
|
||||
})
|
||||
.expect("should discover the rules file");
|
||||
let content = rule["content"].as_str().unwrap();
|
||||
|
|
|
|||
|
|
@ -233,8 +233,8 @@ pub fn persist_trust(store: &mut TrustStore, key: &Path) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether any repo-local code-exec config is present for `cwd`. When none are
|
||||
/// present there is nothing to gate, so we skip the prompt entirely.
|
||||
/// Whether any repo-local trust-sensitive config is present for `cwd`. When none
|
||||
/// are present there is nothing to gate, so we skip the prompt entirely.
|
||||
///
|
||||
/// Thin wrapper over [`collect_repo_config_kinds`] with `first_only = true`, so
|
||||
/// the gate and the display-only [`repo_config_kinds`] enumerate the EXACT same
|
||||
|
|
@ -244,12 +244,13 @@ pub fn repo_configs_present(cwd: &Path) -> bool {
|
|||
!collect_repo_config_kinds(cwd, true).is_empty()
|
||||
}
|
||||
|
||||
/// Display-only: which repo-local code-exec config KINDS are present for `cwd`
|
||||
/// (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`), deduped in
|
||||
/// cheap→expensive marker order. Single source with [`repo_configs_present`]
|
||||
/// (which is `!repo_config_kinds(cwd).is_empty()`), so a folder that the gate
|
||||
/// fired on always has a non-empty, accurate kind list — no `[plugins].paths` /
|
||||
/// `.claude` / `.grok/agents` / subdir-launch gaps. NOT itself the trust gate.
|
||||
/// Display-only: which repo-local trust-sensitive config KINDS are present for
|
||||
/// `cwd` (`mcp`, `plugins`, `lsp`, `envrc`, `claude`, `hooks`, `agents`, `roles`,
|
||||
/// `personas`), deduped in cheap→expensive marker order. Single source with
|
||||
/// [`repo_configs_present`] (which is `!repo_config_kinds(cwd).is_empty()`), so a
|
||||
/// folder that the gate fired on always has a non-empty, accurate kind list — no
|
||||
/// `[plugins].paths` / `.claude` / `.grok/agents` / subdir-launch gaps. NOT
|
||||
/// itself the trust gate.
|
||||
pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> {
|
||||
collect_repo_config_kinds(cwd, false)
|
||||
}
|
||||
|
|
@ -262,6 +263,14 @@ fn path_present_or_uncertain(path: &Path) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
fn directory_present_or_uncertain(path: &Path) -> bool {
|
||||
match std::fs::metadata(path) {
|
||||
Ok(metadata) => metadata.is_dir(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared scanner behind [`repo_configs_present`] and [`repo_config_kinds`]. With
|
||||
/// `first_only` it returns immediately after the first marker (the gate's
|
||||
/// historical short-circuit); otherwise it collects every distinct kind.
|
||||
|
|
@ -376,6 +385,14 @@ fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str>
|
|||
if !xai_grok_agent::discovery::project_agent_dirs_in(&chain.dirs).is_empty() {
|
||||
hit!("agents");
|
||||
}
|
||||
// Presence matches exact-cwd discovery without parsing repository content.
|
||||
let grok = cwd.join(".grok");
|
||||
if directory_present_or_uncertain(&grok.join("roles")) {
|
||||
hit!("roles");
|
||||
}
|
||||
if directory_present_or_uncertain(&grok.join("personas")) {
|
||||
hit!("personas");
|
||||
}
|
||||
// `~/.claude.json` `projects.<cwd>.mcpServers`.
|
||||
if claude_project_mcp_present(cwd) {
|
||||
hit!("mcp");
|
||||
|
|
@ -595,6 +612,64 @@ mod tests {
|
|||
assert!(repo_configs_present(&subdir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_configs_present_detects_project_roles() {
|
||||
let tmp = repo_tmp();
|
||||
std::fs::create_dir_all(tmp.path().join(".grok").join("roles")).unwrap();
|
||||
|
||||
assert!(repo_configs_present(tmp.path()));
|
||||
assert!(repo_config_kinds(tmp.path()).contains(&"roles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_configs_present_detects_project_personas() {
|
||||
let tmp = repo_tmp();
|
||||
std::fs::create_dir_all(tmp.path().join(".grok").join("personas")).unwrap();
|
||||
|
||||
assert!(repo_configs_present(tmp.path()));
|
||||
assert!(repo_config_kinds(tmp.path()).contains(&"personas"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_subagent_marker_regular_file_is_absent() {
|
||||
let tmp = repo_tmp();
|
||||
let grok = tmp.path().join(".grok");
|
||||
std::fs::create_dir_all(&grok).unwrap();
|
||||
std::fs::write(grok.join("roles"), "not a directory").unwrap();
|
||||
assert!(!repo_configs_present(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_subagent_marker_at_repo_root_is_absent_from_subdir() {
|
||||
let tmp = repo_tmp();
|
||||
std::fs::create_dir_all(tmp.path().join(".grok/roles")).unwrap();
|
||||
let subdir = tmp.path().join("nested");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
assert!(!repo_configs_present(&subdir));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_subagent_marker_symlink_to_directory_is_present() {
|
||||
let tmp = repo_tmp();
|
||||
let target = tmp.path().join("target-roles");
|
||||
let grok = tmp.path().join(".grok");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::create_dir_all(&grok).unwrap();
|
||||
std::os::unix::fs::symlink(&target, grok.join("roles")).unwrap();
|
||||
assert!(repo_configs_present(tmp.path()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dangling_project_subagent_marker_is_absent() {
|
||||
let tmp = repo_tmp();
|
||||
let grok = tmp.path().join(".grok");
|
||||
std::fs::create_dir_all(&grok).unwrap();
|
||||
std::os::unix::fs::symlink("missing", grok.join("personas")).unwrap();
|
||||
assert!(!repo_configs_present(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_configs_present_detects_claude_settings_from_subdir() {
|
||||
// A `.claude/settings.json` `env` in a SUBDIR (no other repo config),
|
||||
|
|
|
|||
|
|
@ -2697,6 +2697,9 @@ impl WorkspaceHandle {
|
|||
pub fn session_ids(&self) -> Vec<String> {
|
||||
self.shared.sessions.read().keys().cloned().collect()
|
||||
}
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.shared.sessions.read().len()
|
||||
}
|
||||
/// Fork a new subagent session. Clones (not references) the parent's
|
||||
/// tool config and env. Enforces capability subset and fork budget.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use xai_computer_hub_mcp_adapter::{
|
|||
};
|
||||
use xai_computer_hub_sdk::ToolServerHandler;
|
||||
use xai_grok_mcp::rmcp;
|
||||
use xai_grok_mcp::servers::McpClient;
|
||||
use xai_grok_mcp::servers::{McpClient, parse_mcp_qualified_name};
|
||||
use xai_tool_protocol::ToolId;
|
||||
use xai_tool_runtime::{ToolCallContext, ToolStream, TypedToolOutput};
|
||||
use xai_tool_types::ToolDescription;
|
||||
|
|
@ -143,15 +143,14 @@ pub(crate) struct QualifiedMcpToolHandler {
|
|||
}
|
||||
|
||||
impl QualifiedMcpToolHandler {
|
||||
/// Returns `None` if the qualified name is not a valid `ToolId`.
|
||||
/// Returns `None` if the qualified name is invalid or ambiguous.
|
||||
pub fn try_new(qualified_name: String, inner: Arc<McpToolHandler>) -> Option<Self> {
|
||||
let qualified_id = match ToolId::new(&qualified_name) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
let qualified_id = match parse_mcp_qualified_name(&qualified_name) {
|
||||
Some((id, _, _)) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
qualified_name = %qualified_name,
|
||||
error = %err,
|
||||
"skipping MCP tool: qualified name is not a valid ToolId"
|
||||
qualified_name,
|
||||
"skipping MCP tool: qualified name is invalid or ambiguous"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
|
@ -218,3 +217,60 @@ pub(crate) fn make_bridge_config(
|
|||
namespace: Some(server_name.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use xai_computer_hub_mcp_adapter::{McpBridge, McpError};
|
||||
use xai_tool_protocol::SessionId;
|
||||
|
||||
struct TestTransport;
|
||||
|
||||
#[async_trait]
|
||||
impl McpTransport for TestTransport {
|
||||
async fn initialize(&self) -> Result<McpServerInfo, McpError> {
|
||||
Ok(McpServerInfo {
|
||||
name: "test".to_owned(),
|
||||
version: "1".to_owned(),
|
||||
capabilities: Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDefinition>, McpError> {
|
||||
Ok(vec![McpToolDefinition {
|
||||
name: "tool".to_owned(),
|
||||
description: None,
|
||||
input_schema: None,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
_name: &str,
|
||||
_arguments: Value,
|
||||
) -> Result<McpCallResult, McpError> {
|
||||
unreachable!("constructor test does not call the tool")
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), McpError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qualified_handler_rejects_ambiguous_name() {
|
||||
let bridge = McpBridge::connect(
|
||||
Arc::new(TestTransport),
|
||||
&make_bridge_config(SessionId::new("session").unwrap(), "test"),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.bridge;
|
||||
let inner = bridge.handlers()[0].clone();
|
||||
|
||||
let valid = QualifiedMcpToolHandler::try_new("123__lookup".to_owned(), inner.clone())
|
||||
.expect("valid qualified ToolId");
|
||||
assert_eq!(valid.tool_id().as_str(), "123__lookup");
|
||||
assert!(QualifiedMcpToolHandler::try_new("foo___bar".to_owned(), inner).is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,19 @@ use crate::permission::bash_command_splitting::{
|
|||
use crate::permission::policy::CompiledPolicy;
|
||||
use crate::permission::prompter::{AcpPrompter, PromptOutcome};
|
||||
use crate::permission::shell_access::{
|
||||
combine_decisions, command_write_paths_in_tree, is_safe_write_sink,
|
||||
combine_decisions, command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink,
|
||||
};
|
||||
use crate::permission::state::{PermissionState, load_state_from_disk, persist_state};
|
||||
use crate::permission::types::{
|
||||
AccessKind, ClientType, Decision, EditPolicy, PermissionCommand, PermissionEvent, PromptPolicy,
|
||||
AccessKind, ClientType, Decision, EditPathContext, EditPolicy, PermissionCommand,
|
||||
PermissionEvent, PromptPolicy,
|
||||
};
|
||||
use xai_grok_mcp::servers::parse_mcp_qualified_name;
|
||||
use xai_grok_paths::AbsPathBuf;
|
||||
use xai_grok_tools::implementations::grok_build::web_fetch::{
|
||||
DomainMatcher, domain::normalize_domain,
|
||||
};
|
||||
use xai_grok_tools::types::resources::resolve_model_path;
|
||||
|
||||
/// Canonical `decision_reason` triggers for the uploaded artifact. Single source
|
||||
/// so the emit sites can't drift or misspell (the field doc lists these values).
|
||||
|
|
@ -97,17 +100,11 @@ pub enum PermissionHandle {
|
|||
AllowAll,
|
||||
}
|
||||
|
||||
/// True iff `name` is an MCP tool whose server prefix (everything before the
|
||||
/// first `__`) is in `servers`. The empty-prefix guard rejects corrupt entries
|
||||
/// such as `{""}` or names like `"__tool"`.
|
||||
/// True iff `name` is a valid qualified MCP ID whose server is in `servers`.
|
||||
/// Malformed names fail closed, including `{""}` or names like `"__tool"`.
|
||||
fn mcp_server_prefix_allowed(name: &str, servers: &HashSet<String>) -> bool {
|
||||
if servers.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some((server, _)) = name.split_once("__") else {
|
||||
return false;
|
||||
};
|
||||
!server.is_empty() && servers.contains(server)
|
||||
!servers.is_empty()
|
||||
&& parse_mcp_qualified_name(name).is_some_and(|(_, server, _)| servers.contains(server))
|
||||
}
|
||||
|
||||
/// Pre-decision lookup for an MCP tool. Returns `Some(Decision::Allow)`
|
||||
|
|
@ -669,6 +666,28 @@ impl PermissionHandle {
|
|||
session_id: Option<String>,
|
||||
subagent_type: Option<String>,
|
||||
subagent_description: Option<String>,
|
||||
) -> Decision {
|
||||
self.request_with_edit_path_context(
|
||||
access,
|
||||
tool_call_update,
|
||||
None,
|
||||
session_id,
|
||||
subagent_type,
|
||||
subagent_description,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Request permission with the edit tool's per-session execution cwd.
|
||||
/// Shared parent/subagent managers must use this for `AccessKind::Edit`.
|
||||
pub async fn request_with_edit_path_context(
|
||||
&self,
|
||||
access: AccessKind,
|
||||
tool_call_update: acp::ToolCallUpdate,
|
||||
edit_path_context: Option<EditPathContext>,
|
||||
session_id: Option<String>,
|
||||
subagent_type: Option<String>,
|
||||
subagent_description: Option<String>,
|
||||
) -> Decision {
|
||||
match self {
|
||||
PermissionHandle::AllowAll => Decision::Allow,
|
||||
|
|
@ -682,6 +701,7 @@ impl PermissionHandle {
|
|||
let msg = PermissionCommand::Request {
|
||||
access,
|
||||
tool_call_update,
|
||||
edit_path_context,
|
||||
respond_to: tx,
|
||||
session_id,
|
||||
subagent_type,
|
||||
|
|
@ -1112,6 +1132,7 @@ fn spawn_permission_manager_with_pin(
|
|||
PermissionCommand::Request {
|
||||
access,
|
||||
tool_call_update,
|
||||
edit_path_context,
|
||||
mut respond_to,
|
||||
session_id: request_session_id,
|
||||
subagent_type: request_subagent_type,
|
||||
|
|
@ -1213,6 +1234,23 @@ fn spawn_permission_manager_with_pin(
|
|||
AccessKind::Bash(cmd) => Some(evaluate_bash(cmd, &state, true)),
|
||||
_ => None,
|
||||
};
|
||||
let protected_edit = match (&access, edit_path_context.as_ref()) {
|
||||
(AccessKind::Edit(path), Some(context)) => {
|
||||
let resolved = resolve_model_path(
|
||||
&context.real_cwd,
|
||||
context.display_cwd.as_deref(),
|
||||
path,
|
||||
);
|
||||
edit_target_requires_prompt(&resolved)
|
||||
}
|
||||
// Direct workspace callers predate per-request context and execute
|
||||
// against the manager cwd; the shell always supplies context.
|
||||
(AccessKind::Edit(path), None) => {
|
||||
let resolved = resolve_model_path(cwd.as_path(), None, path);
|
||||
edit_target_requires_prompt(&resolved)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Evaluate managed policy (direct access + per-segment Bash command
|
||||
// rules + Bash shell-file args) up front so the YOLO/sandbox fast
|
||||
|
|
@ -1274,6 +1312,7 @@ fn spawn_permission_manager_with_pin(
|
|||
// Ask floors fall through so managed Ask / shell-file Ask stay binding.
|
||||
if !policy_forced_prompt
|
||||
&& !shell_forced_prompt
|
||||
&& !protected_edit
|
||||
&& let Some((decision, reason)) = session_grant_pre_decision(
|
||||
&access,
|
||||
bash_evaluation.as_ref(),
|
||||
|
|
@ -1307,7 +1346,8 @@ fn spawn_permission_manager_with_pin(
|
|||
AutoFastPath, ClassifierVerdict, access_requires_user_interaction,
|
||||
auto_mode_fast_path,
|
||||
};
|
||||
let needs_user = access_requires_user_interaction(&tool_name, &access);
|
||||
let needs_user =
|
||||
protected_edit || access_requires_user_interaction(&tool_name, &access);
|
||||
let fast = auto_mode_fast_path(&access, &tool_name, needs_user);
|
||||
match fast {
|
||||
AutoFastPath::Allow => {
|
||||
|
|
@ -1421,7 +1461,7 @@ fn spawn_permission_manager_with_pin(
|
|||
// pre-decision match: a policy `Ask` rule on an MCP tool
|
||||
// overrides the session allowlist and forces a re-prompt.
|
||||
// Other access kinds keep their legacy fall-through behavior,
|
||||
// subject to Bash request floors.
|
||||
// subject to Bash request and protected-edit floors.
|
||||
match policy_decision {
|
||||
Some(Decision::Ask) => {
|
||||
tracing::info!(
|
||||
|
|
@ -1431,12 +1471,13 @@ fn spawn_permission_manager_with_pin(
|
|||
);
|
||||
}
|
||||
Some(Decision::Allow)
|
||||
if bash_request_floor_requires_prompt(bash_evaluation.as_ref()) =>
|
||||
if protected_edit
|
||||
|| bash_request_floor_requires_prompt(bash_evaluation.as_ref()) =>
|
||||
{
|
||||
tracing::info!(
|
||||
tool = ?tool_name,
|
||||
source = "policy",
|
||||
"permission policy allow deferred to Bash prompt floor"
|
||||
"permission policy allow deferred to confirmation floor"
|
||||
);
|
||||
}
|
||||
Some(decision) => {
|
||||
|
|
@ -1490,7 +1531,7 @@ fn spawn_permission_manager_with_pin(
|
|||
)
|
||||
.map(|d| (d, reasons::PERSISTED_GRANT)),
|
||||
AccessKind::Edit(_) => {
|
||||
if allow_edits_for_session {
|
||||
if allow_edits_for_session && !protected_edit {
|
||||
Some((Decision::Allow, reasons::PERSISTED_GRANT))
|
||||
} else {
|
||||
match state.edit_policy {
|
||||
|
|
@ -1757,20 +1798,15 @@ fn spawn_permission_manager_with_pin(
|
|||
}
|
||||
PromptOutcome::AllowAlwaysMcpServer(server_prefix) => {
|
||||
// Derive the canonical server prefix from the current
|
||||
// AccessKind via `split_once("__")`. Validate the
|
||||
// client-supplied prefix against it; on mismatch (or
|
||||
// empty / no separator), downgrade to tool-scope using
|
||||
// the access-kind name. This prevents a buggy or
|
||||
// malicious client from whitelisting an unrelated
|
||||
// server.
|
||||
// AccessKind and validate the client-supplied prefix
|
||||
// against it. On mismatch or malformed input, downgrade
|
||||
// to tool-scope using the access-kind name.
|
||||
if let AccessKind::MCPTool {
|
||||
name: access_name, ..
|
||||
} = &access
|
||||
{
|
||||
let canonical = access_name
|
||||
.split_once("__")
|
||||
.map(|(s, _)| s)
|
||||
.filter(|s| !s.is_empty());
|
||||
let canonical = parse_mcp_qualified_name(access_name)
|
||||
.map(|(_, server, _)| server);
|
||||
match canonical {
|
||||
Some(canonical) if canonical == server_prefix => {
|
||||
state
|
||||
|
|
@ -1784,10 +1820,9 @@ fn spawn_permission_manager_with_pin(
|
|||
persist_state(&cwd, &state, client_id_ref).await;
|
||||
}
|
||||
_ => {
|
||||
// Mismatch, empty prefix, or no `__` separator
|
||||
// in the access name. Defensively downgrade to
|
||||
// tool-scope on the access-kind name so the
|
||||
// user is not re-prompted, but the blast
|
||||
// Mismatch or malformed access name. Defensively
|
||||
// downgrade to tool-scope on the access-kind name
|
||||
// so the user is not re-prompted, but the blast
|
||||
// radius is the smaller scope they actually
|
||||
// saw.
|
||||
tracing::warn!(
|
||||
|
|
@ -2115,6 +2150,74 @@ mod tests {
|
|||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_edit_grant_excludes_protected_target() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let transport = fake_hub(serde_json::json!({ "outcome": "always_approve" }));
|
||||
let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone());
|
||||
for path in ["src/first.rs", "src/second.rs", "~/.zshrc"] {
|
||||
assert_eq!(
|
||||
mgr.request(AccessKind::Edit(path.into()), tool_call(), None, None, None)
|
||||
.await,
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
assert_eq!(transport.seen.lock().unwrap().len(), 2);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn shared_manager_uses_request_edit_path_context() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let child = tempfile::tempdir().unwrap();
|
||||
let display = tempfile::tempdir().unwrap();
|
||||
symlink("/etc", child.path().join("link")).unwrap();
|
||||
let parent_cwd = AbsPathBuf::new(parent.path().to_path_buf()).unwrap();
|
||||
let transport = fake_hub(serde_json::json!({ "outcome": "approve" }));
|
||||
let (mgr, _events) = test_manager_with_hub(&parent_cwd, transport.clone());
|
||||
mgr.set_auto_mode(true);
|
||||
let context = EditPathContext {
|
||||
real_cwd: child.path().to_path_buf(),
|
||||
display_cwd: Some(display.path().to_path_buf()),
|
||||
};
|
||||
|
||||
for displayed in [
|
||||
display.path().join("link/hosts"),
|
||||
display.path().join("src.rs"),
|
||||
] {
|
||||
assert_eq!(
|
||||
mgr.request_with_edit_path_context(
|
||||
AccessKind::Edit(displayed.to_string_lossy().into_owned()),
|
||||
tool_call(),
|
||||
Some(context.clone()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
Decision::Allow
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
transport.seen.lock().unwrap().len(),
|
||||
1,
|
||||
"child protected target prompts; ordinary displayed child path stays auto"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_permission_reject_aborts() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
|
@ -2216,6 +2319,64 @@ mod tests {
|
|||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ambiguous_mcp_server_scope_downgrades_to_exact_persisted_grant() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
for (name, forged_server) in [("a__b__c", "a"), ("foo___bar", "foo")] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let transport = fake_hub(serde_json::json!({
|
||||
"outcome": "always_approve",
|
||||
"scope": { "kind": "server_prefix", "value": forged_server },
|
||||
}));
|
||||
let (mgr, _e) = test_manager_with_hub(&cwd, transport.clone());
|
||||
let decision = mgr
|
||||
.request(
|
||||
AccessKind::MCPTool {
|
||||
name: name.into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(decision, Decision::Allow);
|
||||
|
||||
let persisted = load_state_from_disk(&cwd, None).await;
|
||||
assert!(persisted.allowed_mcp_servers.is_empty(), "{name}");
|
||||
assert!(persisted.allowed_mcp_tools.contains(name), "{name}");
|
||||
assert!(matches!(
|
||||
mcp_pre_decision(name, &persisted, false, false),
|
||||
Some(Decision::Allow)
|
||||
));
|
||||
|
||||
let replay_transport = fake_hub(serde_json::json!({ "outcome": "reject" }));
|
||||
let (reloaded, _e) = test_manager_with_hub(&cwd, replay_transport.clone());
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.request(
|
||||
AccessKind::MCPTool {
|
||||
name: name.into(),
|
||||
input: serde_json::Value::Null,
|
||||
},
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
Decision::Allow
|
||||
);
|
||||
assert!(replay_transport.seen.lock().unwrap().is_empty());
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A managed `Ask` rule on a direct `Read`/`Grep` must reach the prompt, not
|
||||
/// the unconditional auto-allow. With no responder wired, that surfaces as a
|
||||
/// non-`Allow` decision; a non-ask read still auto-allows.
|
||||
|
|
@ -3482,6 +3643,59 @@ mod tests {
|
|||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protected_edit_floor_covers_auto_config_allow_and_dont_ask() {
|
||||
use crate::permission::types::{PermissionRule, RuleAction, ToolFilter};
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let mut auto = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
auto.prompt_policy = PromptPolicy::Auto;
|
||||
let allow = crate::permission::types::PermissionConfig::new(vec![PermissionRule {
|
||||
action: RuleAction::Allow,
|
||||
tool: ToolFilter::Edit,
|
||||
pattern: None,
|
||||
pattern_mode: Default::default(),
|
||||
}]);
|
||||
let mut deny = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
deny.prompt_policy = PromptPolicy::Deny;
|
||||
|
||||
for (name, config, expected_prompts, policy_deny) in [
|
||||
("auto", auto, 1, false),
|
||||
("configured allow", allow, 1, false),
|
||||
("dontAsk", deny, 0, true),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let client = RecordingClient::default();
|
||||
let prompts = client.prompts.clone();
|
||||
let (mgr, _events) = manager_with_recording_client(
|
||||
&cwd,
|
||||
Some(config),
|
||||
client,
|
||||
ClientType::Generic,
|
||||
);
|
||||
let decision = mgr
|
||||
.request(
|
||||
AccessKind::Edit("/etc/hosts".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(prompts.borrow().len(), expected_prompts, "{name}");
|
||||
if policy_deny {
|
||||
assert!(matches!(decision, Decision::PolicyDeny(_)), "{name}");
|
||||
} else {
|
||||
assert!(matches!(decision, Decision::Reject(_)), "{name}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_auto_allow_respects_real_file_write_floor() {
|
||||
let state = PermissionState::default();
|
||||
|
|
@ -3558,6 +3772,7 @@ mod tests {
|
|||
.send(PermissionCommand::Request {
|
||||
access: AccessKind::Bash("curl http://example.com".into()),
|
||||
tool_call_update: tool_call(),
|
||||
edit_path_context: None,
|
||||
respond_to: tx,
|
||||
session_id: None,
|
||||
subagent_type: None,
|
||||
|
|
@ -3667,6 +3882,7 @@ mod tests {
|
|||
.send(PermissionCommand::Request {
|
||||
access: AccessKind::Bash("curl http://example.com".into()),
|
||||
tool_call_update: tool_call(),
|
||||
edit_path_context: None,
|
||||
respond_to: tx,
|
||||
session_id: None,
|
||||
subagent_type: None,
|
||||
|
|
@ -5139,10 +5355,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn server_prefix_match_allows() {
|
||||
assert!(mcp_server_prefix_allowed(
|
||||
"linear__list",
|
||||
&servers(&["linear"])
|
||||
));
|
||||
for (name, server) in [
|
||||
("linear__list", "linear"),
|
||||
("123__lookup", "123"),
|
||||
("server:scope__tool", "server:scope"),
|
||||
] {
|
||||
assert!(mcp_server_prefix_allowed(name, &servers(&[server])));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -5151,8 +5370,24 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn no_separator_rejects() {
|
||||
assert!(!mcp_server_prefix_allowed("linear", &servers(&["linear"])));
|
||||
fn malformed_names_do_not_consume_server_grants() {
|
||||
for (name, server) in [
|
||||
("server__part__tool", "server"),
|
||||
("server__tool__part", "server"),
|
||||
("foo___bar", "foo"),
|
||||
("foo___bar", "foo_"),
|
||||
("foo____bar", "foo"),
|
||||
("server__", "server"),
|
||||
("server", "server"),
|
||||
("__tool", ""),
|
||||
("", ""),
|
||||
("server__bad.tool", "server"),
|
||||
] {
|
||||
assert!(
|
||||
!mcp_server_prefix_allowed(name, &servers(&[server])),
|
||||
"unexpectedly allowed {name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -5172,9 +5407,8 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn first_double_underscore_anchors_split() {
|
||||
// "a__b__c" splits into ("a", "b__c"); server "a" matches.
|
||||
assert!(mcp_server_prefix_allowed("a__b__c", &servers(&["a"])));
|
||||
fn multiple_delimiters_do_not_inherit_first_segment_grant() {
|
||||
assert!(!mcp_server_prefix_allowed("a__b__c", &servers(&["a"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -5187,21 +5421,17 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tool_name_after_prefix_still_allowed() {
|
||||
// The MCP server is responsible for rejecting empty tool names;
|
||||
// the prefix match is what gates access.
|
||||
assert!(mcp_server_prefix_allowed("foo__", &servers(&["foo"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_decision_tool_grant_allows() {
|
||||
let mut state = PermissionState::default();
|
||||
state.allowed_mcp_tools.insert("linear__list".to_string());
|
||||
assert!(matches!(
|
||||
mcp_pre_decision("linear__list", &state, false, false),
|
||||
Some(Decision::Allow)
|
||||
));
|
||||
state.allowed_mcp_tools.insert("a__b__c".to_string());
|
||||
for name in ["linear__list", "a__b__c"] {
|
||||
assert!(matches!(
|
||||
mcp_pre_decision(name, &state, false, false),
|
||||
Some(Decision::Allow)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -5360,10 +5590,8 @@ mod tests {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Auto mode accepts ALL file edits via the fast path regardless of location
|
||||
/// (the accept-all-edits product decision, no workspace restriction): both an
|
||||
/// in-cwd edit and an absolute path clearly OUTSIDE cwd fast-path Allow. The
|
||||
/// fast path is path-independent, so the target file need not exist.
|
||||
/// Auto mode accepts ordinary file edits via the fast path regardless of
|
||||
/// location (the accept-all-edits product decision, no workspace restriction).
|
||||
#[tokio::test]
|
||||
async fn auto_mode_edit_fast_path_allows() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
|
@ -5380,7 +5608,6 @@ mod tests {
|
|||
)
|
||||
};
|
||||
|
||||
// In-cwd edit → Allow (file need not exist).
|
||||
let in_cwd = tmp.path().join("f.rs").to_string_lossy().into_owned();
|
||||
let d = mgr
|
||||
.request(AccessKind::Edit(in_cwd), mk("tc-edit-in"), None, None, None)
|
||||
|
|
@ -5390,10 +5617,9 @@ mod tests {
|
|||
"in-cwd edit under auto must fast-path allow, got {d:?}"
|
||||
);
|
||||
|
||||
// Out-of-workspace absolute edit → Allow too (no workspace restriction).
|
||||
let d = mgr
|
||||
.request(
|
||||
AccessKind::Edit("/etc/hosts".into()),
|
||||
AccessKind::Edit("/tmp/out-of-ws.rs".into()),
|
||||
mk("tc-edit-out"),
|
||||
None,
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::permission::{
|
|||
use agent_client_protocol::{self as acp, Client as _};
|
||||
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use xai_file_utils::events::{Event, EventWriter, PermissionDecision};
|
||||
use xai_grok_mcp::servers::parse_mcp_qualified_name;
|
||||
use xai_grok_tools::implementations::grok_build::web_fetch::domain_from_url;
|
||||
|
||||
const REJECT_ONCE_LABEL: &str = "No, and tell Grok what to do differently";
|
||||
|
|
@ -162,9 +163,8 @@ pub struct BashCommandSelectedTerms {
|
|||
/// depend on it without dragging the full workspace or rmcp into each
|
||||
/// other). Re-exported here for backward-compat with callers that historically
|
||||
/// reached `xai_grok_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.
|
||||
/// Model-callable MCP registration validates this delimiter before permission
|
||||
/// handling, so stripping it given a trusted `server_prefix` is unambiguous.
|
||||
pub use xai_grok_workspace_types::MCP_TOOL_NAME_DELIMITER;
|
||||
|
||||
/// Extract the action segment of a qualified MCP tool name using a
|
||||
|
|
@ -222,15 +222,13 @@ pub fn mcp_tool_display_name(tool_name: &str, server_prefix: Option<&str>) -> St
|
|||
|
||||
/// 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).
|
||||
/// or scrollback blocks that store the wire name verbatim). Valid qualified
|
||||
/// names are 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).
|
||||
pub fn mcp_pretty_name_if_qualified(name: &str) -> String {
|
||||
match name.split_once(MCP_TOOL_NAME_DELIMITER) {
|
||||
Some((server, action)) => format!(
|
||||
match parse_mcp_qualified_name(name) {
|
||||
Some((_, server, action)) => format!(
|
||||
"({}) {}",
|
||||
mcp_titleize_segment(server),
|
||||
mcp_titleize_segment(action)
|
||||
|
|
@ -250,10 +248,9 @@ pub struct McpToolPermission {
|
|||
/// Full tool name as the agent called it
|
||||
/// (e.g. `"grok_com_notion__notion-fetch"`).
|
||||
pub tool_name: String,
|
||||
/// Server segment (everything before the single `__` separator,
|
||||
/// e.g. `"grok_com_notion"`). `None` if the tool name has no `__`,
|
||||
/// in which case the view hides the scope toggle and only offers
|
||||
/// tool-scope.
|
||||
/// Server component of a valid qualified MCP ID (e.g. `"grok_com_notion"`).
|
||||
/// `None` for malformed or unqualified names, in which case the view hides
|
||||
/// the scope toggle and only offers tool-scope.
|
||||
pub server_prefix: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -277,7 +274,7 @@ impl McpToolPermission {
|
|||
pub enum McpScopeSelection {
|
||||
/// Whitelist exactly this tool name.
|
||||
Tool { tool_name: String },
|
||||
/// Whitelist every tool whose name starts with `<server>__`.
|
||||
/// Whitelist the server component of the current valid qualified MCP ID.
|
||||
Server { server: String },
|
||||
}
|
||||
|
||||
|
|
@ -293,8 +290,8 @@ pub enum PromptOutcome {
|
|||
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.
|
||||
/// Persist the current valid qualified MCP ID's server component in
|
||||
/// `allowed_mcp_servers`; the manager rejects mismatched or malformed input.
|
||||
AllowAlwaysMcpServer(String),
|
||||
RejectOnce,
|
||||
RejectAlwaysBashCommand(String),
|
||||
|
|
@ -671,7 +668,8 @@ impl AcpPrompter {
|
|||
ClientType::GrokTUI | ClientType::GrokPager | ClientType::Desktop => {
|
||||
let mut options: IndexMap<acp::PermissionOptionId, acp::PermissionOption> =
|
||||
IndexMap::new();
|
||||
let server_prefix = tool_name.split_once("__").map(|(s, _)| s.to_owned());
|
||||
let server_prefix = parse_mcp_qualified_name(tool_name)
|
||||
.map(|(_, server, _)| server.to_owned());
|
||||
options.insert(
|
||||
acp::PermissionOptionId::new("allow-always-mcp"),
|
||||
acp::PermissionOption::new(
|
||||
|
|
@ -1169,37 +1167,46 @@ mod tests {
|
|||
#[test]
|
||||
fn mcp_prompt_includes_allow_always_with_meta() {
|
||||
let p = prompter(ClientType::GrokTUI);
|
||||
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:");
|
||||
for (name, server) in [
|
||||
("linear__list", "linear"),
|
||||
("123__lookup", "123"),
|
||||
("server:scope__tool", "server:scope"),
|
||||
] {
|
||||
let access = AccessKind::MCPTool {
|
||||
name: name.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, name);
|
||||
assert_eq!(perm.server_prefix.as_deref(), Some(server));
|
||||
assert_eq!(perm.prompt_prefix, "Always allow:");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_prompt_no_separator_hides_server_scope() {
|
||||
fn mcp_prompt_malformed_name_hides_server_scope() {
|
||||
let p = prompter(ClientType::GrokPager);
|
||||
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);
|
||||
for name in ["standalone", "linear__shadow__exfil", "linear__"] {
|
||||
let access = AccessKind::MCPTool {
|
||||
name: name.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, name);
|
||||
assert_eq!(perm.server_prefix, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1317,11 +1324,20 @@ mod tests {
|
|||
mcp_pretty_name_if_qualified("linear__list_issues"),
|
||||
"(Linear) List Issues"
|
||||
);
|
||||
assert_eq!(mcp_pretty_name_if_qualified("123__lookup"), "(123) Lookup");
|
||||
assert_eq!(
|
||||
mcp_pretty_name_if_qualified("server:scope__tool"),
|
||||
"(Server:scope) Tool"
|
||||
);
|
||||
// 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("linear__shadow__exfil"),
|
||||
"linear__shadow__exfil"
|
||||
);
|
||||
assert_eq!(mcp_pretty_name_if_qualified(""), "");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -230,6 +230,84 @@ pub(crate) fn is_safe_write_sink(path: &str) -> bool {
|
|||
matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr")
|
||||
}
|
||||
|
||||
/// Whether an already-resolved direct edit target needs explicit confirmation.
|
||||
///
|
||||
/// The caller uses the edit tools' shared model-path resolver first. This helper
|
||||
/// preserves its uncollapsed components for physical symlink + `..` resolution,
|
||||
/// while checking a separate lexical normalization for traversal aliases.
|
||||
pub(crate) fn edit_target_requires_prompt(path: &Path) -> bool {
|
||||
if !path.is_absolute() {
|
||||
return true;
|
||||
}
|
||||
let lexical = xai_grok_paths::normalize_lexically(path);
|
||||
if protected_edit_path(&lexical) {
|
||||
return true;
|
||||
}
|
||||
let Some(resolved) = resolve_following_symlinks(path, 0) else {
|
||||
return true;
|
||||
};
|
||||
protected_edit_path(&resolved) || resolved_path_is_within_root(&resolved, Path::new("/etc"))
|
||||
}
|
||||
|
||||
fn protected_edit_path(path: &Path) -> bool {
|
||||
let components: Vec<String> = path
|
||||
.components()
|
||||
.filter_map(|component| match component {
|
||||
std::path::Component::Normal(part) => Some(part.to_string_lossy().to_ascii_lowercase()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let string_components: Vec<&str> = components.iter().map(String::as_str).collect();
|
||||
let file = string_components.last().copied().unwrap_or("");
|
||||
const STARTUP_FILES: &[&str] = &[
|
||||
".bashrc",
|
||||
".bash_profile",
|
||||
".bash_login",
|
||||
".bash_logout",
|
||||
".profile",
|
||||
".zshrc",
|
||||
".zshenv",
|
||||
".zprofile",
|
||||
".zlogin",
|
||||
".zlogout",
|
||||
".kshrc",
|
||||
".cshrc",
|
||||
".tcshrc",
|
||||
".login",
|
||||
".logout",
|
||||
".inputrc",
|
||||
".xprofile",
|
||||
];
|
||||
|
||||
STARTUP_FILES.contains(&file)
|
||||
|| protected_git_hooks_path(&string_components)
|
||||
|| string_components.contains(&".ssh")
|
||||
|| string_components.ends_with(&[".grok", "config.toml"])
|
||||
|| path == Path::new("/etc")
|
||||
|| path.starts_with(Path::new("/etc"))
|
||||
}
|
||||
|
||||
fn protected_git_hooks_path(components: &[&str]) -> bool {
|
||||
components.windows(2).any(|pair| pair == [".git", "hooks"])
|
||||
|| components.iter().enumerate().any(|(git, component)| {
|
||||
*component == ".git"
|
||||
&& components.get(git + 1) == Some(&"modules")
|
||||
&& components[git + 2..]
|
||||
.iter()
|
||||
.skip(1)
|
||||
.any(|component| *component == "hooks")
|
||||
})
|
||||
}
|
||||
|
||||
/// `resolved_path` is already physical; resolve `root` so platform aliases such
|
||||
/// as macOS `/etc -> /private/etc` compare in the same namespace. Resolution
|
||||
/// failure is conservative: the caller then requires confirmation.
|
||||
fn resolved_path_is_within_root(resolved_path: &Path, root: &Path) -> bool {
|
||||
resolve_following_symlinks(root, 0)
|
||||
.map(|resolved_root| resolved_path.starts_with(resolved_root))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ShellFileMode {
|
||||
Read,
|
||||
|
|
@ -738,8 +816,8 @@ fn resolve_symlink_target(absolute: &str) -> Option<String> {
|
|||
|
||||
/// Resolve `path` following every symlink, including a *dangling* final link
|
||||
/// (which `canonicalize` alone rejects) and not-yet-existing trailing
|
||||
/// components. Depth-bounded against cycles; any fs error yields `None`.
|
||||
/// Blocking fs syscalls; runs per operand when file rules exist.
|
||||
/// components. Depth-bounded against cycles; unexpected fs errors yield `None`.
|
||||
/// Blocking fs syscalls; runs for shell operands under file rules and direct edits.
|
||||
fn resolve_following_symlinks(path: &Path, depth: usize) -> Option<PathBuf> {
|
||||
const MAX_SYMLINK_DEPTH: usize = 40;
|
||||
if depth > MAX_SYMLINK_DEPTH {
|
||||
|
|
@ -750,13 +828,17 @@ fn resolve_following_symlinks(path: &Path, depth: usize) -> Option<PathBuf> {
|
|||
return Some(canonical);
|
||||
}
|
||||
// Resolve the parent, then the final component, so a dangling/new leaf still follows.
|
||||
// Missing components are valid new paths; other metadata errors fail closed.
|
||||
let parent = path.parent()?;
|
||||
let file_name = path.file_name()?;
|
||||
let resolved_parent = resolve_following_symlinks(parent, depth + 1)?;
|
||||
let candidate = resolved_parent.join(file_name);
|
||||
if let Ok(meta) = std::fs::symlink_metadata(&candidate)
|
||||
&& meta.file_type().is_symlink()
|
||||
{
|
||||
let metadata = match std::fs::symlink_metadata(&candidate) {
|
||||
Ok(metadata) => Some(metadata),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if metadata.is_some_and(|metadata| metadata.file_type().is_symlink()) {
|
||||
// A symlink must be followed; if it can't be read, treat the whole path
|
||||
// as unresolved (`None`) rather than returning the link's own path.
|
||||
let target = std::fs::read_link(&candidate).ok()?;
|
||||
|
|
@ -818,6 +900,111 @@ mod tests {
|
|||
std::path::Path::new("/work")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_edit_targets_and_lexical_aliases_prompt() {
|
||||
for path in [
|
||||
"/home/user/.zshrc",
|
||||
"/etc",
|
||||
"/etc/grok-test",
|
||||
"/work/subdir/../.git/hooks/pre-commit",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_requires_prompt(Path::new(path)),
|
||||
"protected edit target must prompt: {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/work/src/main.rs",
|
||||
"/work/project/.grok/config.toml/backup",
|
||||
] {
|
||||
assert!(
|
||||
!edit_target_requires_prompt(Path::new(path)),
|
||||
"ordinary edit target should not prompt: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_edit_targets_include_submodule_hooks() {
|
||||
for path in [
|
||||
"/work/.git/modules/foo/hooks/pre-commit",
|
||||
"/work/.git/modules/submodules/sglang-private/hooks/pre-commit",
|
||||
"/work/.git/modules/outer/modules/inner/hooks/pre-commit",
|
||||
"/work/subdir/../.git/modules/foo/hooks/pre-commit",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_requires_prompt(Path::new(path)),
|
||||
"submodule hook target must prompt: {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/work/.git/modules/hooks/pre-commit",
|
||||
"/work/.git/module/foo/hooks/pre-commit",
|
||||
"/work/.git/modules/foo/hook/pre-commit",
|
||||
"/work/.git/modules/foo/hooks-disabled/pre-commit",
|
||||
"/work/src/modules/foo/hooks/pre-commit",
|
||||
] {
|
||||
assert!(
|
||||
!edit_target_requires_prompt(Path::new(path)),
|
||||
"non-hook control must not prompt: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn sensitive_edit_targets_follow_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let ws = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let startup = outside.path().join(".zshrc");
|
||||
std::fs::write(&startup, b"").unwrap();
|
||||
symlink(&startup, ws.path().join("file-link")).unwrap();
|
||||
std::fs::create_dir_all(outside.path().join(".git/hooks")).unwrap();
|
||||
symlink(
|
||||
outside.path().join(".git/hooks"),
|
||||
ws.path().join("hooks-link"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(outside.path().join(".git/modules/foo/hooks")).unwrap();
|
||||
symlink(
|
||||
outside.path().join(".git/modules/foo/hooks"),
|
||||
ws.path().join("module-hooks-link"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for path in [
|
||||
ws.path().join("file-link"),
|
||||
ws.path().join("hooks-link/new-hook"),
|
||||
ws.path().join("module-hooks-link/new-hook"),
|
||||
] {
|
||||
assert!(
|
||||
edit_target_requires_prompt(&path),
|
||||
"symlinked protected edit target must prompt: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_root_alias_matches_physical_destination() {
|
||||
let resolved_root = resolve_following_symlinks(Path::new("/etc"), 0).unwrap();
|
||||
assert!(resolved_path_is_within_root(
|
||||
&resolved_root.join("grok-test"),
|
||||
Path::new("/etc")
|
||||
));
|
||||
assert!(!resolved_path_is_within_root(
|
||||
Path::new("/tmp/grok-test"),
|
||||
Path::new("/etc")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn private_etc_alias_requires_prompt() {
|
||||
assert!(edit_target_requires_prompt(Path::new("/private/etc/hosts")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn resolved_symlink_target_hits_read_deny() {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use std::collections::HashSet;
|
|||
use xai_grok_paths::AbsPathBuf;
|
||||
use xai_grok_tools::util::grok_home::grok_home;
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
const VALIDATED_MCP_SERVER_GRANTS_VERSION: i64 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PermissionState {
|
||||
pub edit_policy: EditPolicy,
|
||||
|
|
@ -19,11 +21,47 @@ pub struct PermissionState {
|
|||
/// Exact MCP tool names (e.g. `"grok_com_notion__notion-fetch"`)
|
||||
/// the user has granted "always allow" for. Lookup is exact.
|
||||
pub allowed_mcp_tools: HashSet<String>,
|
||||
/// MCP server prefixes (everything before the first `__`,
|
||||
/// e.g. `"grok_com_notion"`) for which the user has granted
|
||||
/// "always allow" to every tool. Lookup is "tool name starts with
|
||||
/// `<prefix>__`".
|
||||
/// Server components of valid qualified MCP IDs (e.g. `"grok_com_notion"`)
|
||||
/// for which the user has granted "always allow" to every tool. Lookup
|
||||
/// validates and parses the complete qualified ID before matching.
|
||||
pub allowed_mcp_servers: HashSet<String>,
|
||||
/// Version proving server-wide grants were minted from validated qualified IDs.
|
||||
/// Missing or malformed markers are legacy; future integer versions are preserved.
|
||||
#[serde(
|
||||
default = "legacy_mcp_server_grants_version",
|
||||
deserialize_with = "deserialize_mcp_server_grants_version"
|
||||
)]
|
||||
pub(crate) validated_mcp_server_grants_version: i64,
|
||||
}
|
||||
|
||||
fn legacy_mcp_server_grants_version() -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
fn deserialize_mcp_server_grants_version<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = toml::Value::deserialize(deserializer)?;
|
||||
Ok(match value.as_integer() {
|
||||
Some(version) if version >= 0 => version,
|
||||
_ => 0,
|
||||
})
|
||||
}
|
||||
|
||||
impl Default for PermissionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
edit_policy: EditPolicy::default(),
|
||||
allow_bash_execute: false,
|
||||
allowed_bash_commands: HashSet::new(),
|
||||
disallowed_bash_commands: HashSet::new(),
|
||||
allowed_web_fetch_domains: HashSet::new(),
|
||||
allowed_mcp_tools: HashSet::new(),
|
||||
allowed_mcp_servers: HashSet::new(),
|
||||
validated_mcp_server_grants_version: VALIDATED_MCP_SERVER_GRANTS_VERSION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_dir_for_cwd(cwd: &AbsPathBuf) -> std::path::PathBuf {
|
||||
|
|
@ -49,9 +87,23 @@ fn state_file_path(dir: &std::path::Path, client_identifier: Option<&str>) -> st
|
|||
}
|
||||
}
|
||||
|
||||
async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
|
||||
async fn try_load_state_with_writer<F>(path: &std::path::Path, writer: F) -> Option<PermissionState>
|
||||
where
|
||||
F: FnOnce(&std::path::Path, &str) -> std::io::Result<()> + Send + 'static,
|
||||
{
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(s) => Some(toml::from_str(&s).unwrap_or_default()),
|
||||
Ok(s) => {
|
||||
let mut state: PermissionState = toml::from_str(&s).unwrap_or_default();
|
||||
if state.validated_mcp_server_grants_version < VALIDATED_MCP_SERVER_GRANTS_VERSION {
|
||||
state.allowed_mcp_servers.clear();
|
||||
state.validated_mcp_server_grants_version = VALIDATED_MCP_SERVER_GRANTS_VERSION;
|
||||
tracing::info!(path = %path.display(), "invalidated legacy MCP server grants");
|
||||
if let Err(e) = persist_state_to_path_with_writer(path, &state, writer).await {
|
||||
tracing::warn!(?e, path = %path.display(), "failed writing permission state");
|
||||
}
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "failed reading permission state");
|
||||
|
|
@ -60,6 +112,13 @@ async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
|
|||
}
|
||||
}
|
||||
|
||||
async fn try_load_state(path: &std::path::Path) -> Option<PermissionState> {
|
||||
try_load_state_with_writer(path, |path, contents| {
|
||||
xai_grok_config::fs_atomic::write_atomically(path, contents, None)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_state_from_dir(
|
||||
dir: &std::path::Path,
|
||||
client_identifier: Option<&str>,
|
||||
|
|
@ -69,12 +128,10 @@ async fn load_state_from_dir(
|
|||
if let Some(state) = try_load_state(&per_client).await {
|
||||
return state;
|
||||
}
|
||||
let shared = state_file_path(dir, None);
|
||||
try_load_state(&shared).await.unwrap_or_default()
|
||||
} else {
|
||||
let path = state_file_path(dir, None);
|
||||
try_load_state(&path).await.unwrap_or_default()
|
||||
}
|
||||
try_load_state(&state_file_path(dir, None))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn load_state_from_disk(
|
||||
|
|
@ -84,6 +141,32 @@ pub(crate) async fn load_state_from_disk(
|
|||
load_state_from_dir(&state_dir_for_cwd(cwd), client_identifier).await
|
||||
}
|
||||
|
||||
async fn persist_state_to_path_with_writer<F>(
|
||||
path: &std::path::Path,
|
||||
state: &PermissionState,
|
||||
writer: F,
|
||||
) -> std::io::Result<()>
|
||||
where
|
||||
F: FnOnce(&std::path::Path, &str) -> std::io::Result<()> + Send + 'static,
|
||||
{
|
||||
let contents = toml::to_string_pretty(state)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let path = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || writer(&path, &contents))
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
async fn persist_state_to_path(
|
||||
path: &std::path::Path,
|
||||
state: &PermissionState,
|
||||
) -> std::io::Result<()> {
|
||||
persist_state_to_path_with_writer(path, state, |path, contents| {
|
||||
xai_grok_config::fs_atomic::write_atomically(path, contents, None)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn persist_state_to_dir(
|
||||
dir: &std::path::Path,
|
||||
state: &PermissionState,
|
||||
|
|
@ -94,13 +177,8 @@ async fn persist_state_to_dir(
|
|||
return;
|
||||
}
|
||||
let path = state_file_path(dir, client_identifier);
|
||||
match toml::to_string_pretty(state) {
|
||||
Ok(s) => {
|
||||
if let Err(e) = tokio::fs::write(&path, s).await {
|
||||
tracing::warn!(?e, "failed writing permission state");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(?e, "failed serializing permission state"),
|
||||
if let Err(e) = persist_state_to_path(&path, state).await {
|
||||
tracing::warn!(?e, path = %path.display(), "failed writing permission state");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +239,10 @@ mod tests {
|
|||
assert!(!restored.allow_bash_execute);
|
||||
assert!(restored.allowed_bash_commands.is_empty());
|
||||
assert!(restored.disallowed_bash_commands.is_empty());
|
||||
assert_eq!(
|
||||
restored.validated_mcp_server_grants_version,
|
||||
VALIDATED_MCP_SERVER_GRANTS_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -251,11 +333,12 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_from_empty_toml() {
|
||||
fn deserialize_from_empty_toml_is_legacy() {
|
||||
let state: PermissionState = toml::from_str("").unwrap();
|
||||
assert!(!state.allow_bash_execute);
|
||||
assert!(state.allowed_bash_commands.is_empty());
|
||||
assert!(state.disallowed_bash_commands.is_empty());
|
||||
assert_eq!(state.validated_mcp_server_grants_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -364,6 +447,16 @@ allowed_web_fetch_domains = ["github.com"]
|
|||
assert!(state.allowed_web_fetch_domains.contains("github.com"));
|
||||
assert!(state.allowed_mcp_tools.is_empty());
|
||||
assert!(state.allowed_mcp_servers.is_empty());
|
||||
assert_eq!(state.validated_mcp_server_grants_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mcp_server_grants_version_is_legacy() {
|
||||
for marker in ["-1", "\"invalid\""] {
|
||||
let state: PermissionState =
|
||||
toml::from_str(&format!("validated_mcp_server_grants_version = {marker}")).unwrap();
|
||||
assert_eq!(state.validated_mcp_server_grants_version, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -387,13 +480,139 @@ allowed_bash_commands = ["ls"]
|
|||
|
||||
// ── Disk persistence roundtrip tests ─────────────────────────
|
||||
|
||||
async fn write_legacy_mcp_state(path: &std::path::Path) {
|
||||
tokio::fs::write(
|
||||
path,
|
||||
r#"
|
||||
edit_policy = "reject"
|
||||
allow_bash_execute = true
|
||||
allowed_bash_commands = ["cargo test"]
|
||||
disallowed_bash_commands = ["rm"]
|
||||
allowed_web_fetch_domains = ["example.com"]
|
||||
allowed_mcp_tools = ["a__b__c"]
|
||||
allowed_mcp_servers = ["a"]
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assert_legacy_mcp_state_migrated(state: &PermissionState) {
|
||||
assert!(state.allowed_mcp_servers.is_empty());
|
||||
assert!(state.allowed_mcp_tools.contains("a__b__c"));
|
||||
assert!(state.allow_bash_execute);
|
||||
assert!(state.allowed_bash_commands.contains("cargo test"));
|
||||
assert!(state.disallowed_bash_commands.contains("rm"));
|
||||
assert!(state.allowed_web_fetch_domains.contains("example.com"));
|
||||
assert_eq!(state.edit_policy, EditPolicy::Reject);
|
||||
assert_eq!(
|
||||
state.validated_mcp_server_grants_version,
|
||||
VALIDATED_MCP_SERVER_GRANTS_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_shared_mcp_server_grants_migrate_and_rewrite() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = state_file_path(tmp.path(), None);
|
||||
write_legacy_mcp_state(&path).await;
|
||||
|
||||
assert_legacy_mcp_state_migrated(&load_state_from_dir(tmp.path(), None).await);
|
||||
let rewritten: PermissionState =
|
||||
toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
|
||||
assert_legacy_mcp_state_migrated(&rewritten);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_migration_rewrite_preserves_legacy_file_for_retry() {
|
||||
fn fail_write(_: &std::path::Path, _: &str) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("injected write failure"))
|
||||
}
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = state_file_path(tmp.path(), None);
|
||||
write_legacy_mcp_state(&path).await;
|
||||
let legacy_contents = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
|
||||
let in_memory = try_load_state_with_writer(&path, fail_write).await.unwrap();
|
||||
assert_legacy_mcp_state_migrated(&in_memory);
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&path).await.unwrap(),
|
||||
legacy_contents
|
||||
);
|
||||
let still_legacy: PermissionState = toml::from_str(&legacy_contents).unwrap();
|
||||
assert_eq!(still_legacy.validated_mcp_server_grants_version, 0);
|
||||
assert!(still_legacy.allowed_mcp_servers.contains("a"));
|
||||
|
||||
assert_legacy_mcp_state_migrated(&try_load_state(&path).await.unwrap());
|
||||
let rewritten: PermissionState =
|
||||
toml::from_str(&tokio::fs::read_to_string(&path).await.unwrap()).unwrap();
|
||||
assert_legacy_mcp_state_migrated(&rewritten);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_and_future_mcp_server_grants_are_retained_exactly() {
|
||||
for version in [
|
||||
VALIDATED_MCP_SERVER_GRANTS_VERSION,
|
||||
VALIDATED_MCP_SERVER_GRANTS_VERSION + 1,
|
||||
4_294_967_296,
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut state = PermissionState::default();
|
||||
state.validated_mcp_server_grants_version = version;
|
||||
state.allowed_mcp_servers.insert("linear".to_owned());
|
||||
persist_state_to_dir(tmp.path(), &state, None).await;
|
||||
|
||||
let loaded = load_state_from_dir(tmp.path(), None).await;
|
||||
assert!(loaded.allowed_mcp_servers.contains("linear"));
|
||||
assert_eq!(loaded.validated_mcp_server_grants_version, version);
|
||||
let persisted: PermissionState = toml::from_str(
|
||||
&tokio::fs::read_to_string(state_file_path(tmp.path(), None))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(persisted.validated_mcp_server_grants_version, version);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_client_legacy_migration_rewrites_only_loaded_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let shared = state_file_path(tmp.path(), None);
|
||||
let per_client = state_file_path(tmp.path(), Some("desktop"));
|
||||
let mut shared_state = PermissionState::default();
|
||||
shared_state.allowed_mcp_servers.insert("shared".to_owned());
|
||||
persist_state_to_dir(tmp.path(), &shared_state, None).await;
|
||||
write_legacy_mcp_state(&per_client).await;
|
||||
|
||||
assert_legacy_mcp_state_migrated(&load_state_from_dir(tmp.path(), Some("desktop")).await);
|
||||
let shared_after: PermissionState =
|
||||
toml::from_str(&tokio::fs::read_to_string(shared).await.unwrap()).unwrap();
|
||||
assert!(shared_after.allowed_mcp_servers.contains("shared"));
|
||||
let client_after: PermissionState =
|
||||
toml::from_str(&tokio::fs::read_to_string(per_client).await.unwrap()).unwrap();
|
||||
assert_legacy_mcp_state_migrated(&client_after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_client_fallback_migrates_shared_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let shared = state_file_path(tmp.path(), None);
|
||||
write_legacy_mcp_state(&shared).await;
|
||||
|
||||
assert_legacy_mcp_state_migrated(
|
||||
&load_state_from_dir(tmp.path(), Some("missing-client")).await,
|
||||
);
|
||||
let shared_after: PermissionState =
|
||||
toml::from_str(&tokio::fs::read_to_string(shared).await.unwrap()).unwrap();
|
||||
assert_legacy_mcp_state_migrated(&shared_after);
|
||||
assert!(!state_file_path(tmp.path(), Some("missing-client")).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_and_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd_path = tmp.path().join("my-project");
|
||||
std::fs::create_dir_all(&cwd_path).unwrap();
|
||||
let _cwd = AbsPathBuf::new(cwd_path).unwrap();
|
||||
|
||||
let mut state = PermissionState::default();
|
||||
state.allow_bash_execute = true;
|
||||
state
|
||||
|
|
@ -401,18 +620,8 @@ allowed_bash_commands = ["ls"]
|
|||
.insert("cargo build".to_string());
|
||||
state.disallowed_bash_commands.insert("rm -rf".to_string());
|
||||
|
||||
// Override the state dir to use our temp dir.
|
||||
// We can't easily override grok_home(), so instead test
|
||||
// the serialize/deserialize path directly with TOML.
|
||||
let toml_str = toml::to_string_pretty(&state).unwrap();
|
||||
let dir = tmp.path().join("sessions").join("test");
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
let path = dir.join("permission.toml");
|
||||
tokio::fs::write(&path, &toml_str).await.unwrap();
|
||||
|
||||
let content = tokio::fs::read_to_string(&path).await.unwrap();
|
||||
let restored: PermissionState = toml::from_str(&content).unwrap();
|
||||
|
||||
persist_state_to_dir(tmp.path(), &state, None).await;
|
||||
let restored = load_state_from_dir(tmp.path(), None).await;
|
||||
assert!(restored.allow_bash_execute);
|
||||
assert!(restored.allowed_bash_commands.contains("cargo build"));
|
||||
assert!(restored.disallowed_bash_commands.contains("rm -rf"));
|
||||
|
|
@ -492,11 +701,17 @@ allowed_bash_commands = ["ls"]
|
|||
async fn try_load_state_valid_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("permission.toml");
|
||||
tokio::fs::write(&path, "allow_bash_execute = true")
|
||||
let mut expected = PermissionState::default();
|
||||
expected.allow_bash_execute = true;
|
||||
tokio::fs::write(&path, toml::to_string_pretty(&expected).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let state = try_load_state(&path).await.unwrap();
|
||||
assert!(state.allow_bash_execute);
|
||||
assert_eq!(
|
||||
state.validated_mcp_server_grants_version,
|
||||
VALIDATED_MCP_SERVER_GRANTS_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -211,11 +211,17 @@ impl<'de> Deserialize<'de> for EditPolicy {
|
|||
deserializer.deserialize_str(V)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditPathContext {
|
||||
pub real_cwd: std::path::PathBuf,
|
||||
pub display_cwd: Option<std::path::PathBuf>,
|
||||
}
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum PermissionCommand {
|
||||
Request {
|
||||
access: AccessKind,
|
||||
tool_call_update: acp::ToolCallUpdate,
|
||||
edit_path_context: Option<EditPathContext>,
|
||||
respond_to: oneshot::Sender<Decision>,
|
||||
/// Session ID originating this request. Used to attribute
|
||||
/// permission events to child subagents.
|
||||
|
|
|
|||
|
|
@ -475,7 +475,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||
}
|
||||
fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend {
|
||||
crate::config::SessionTerminalBackend::local(
|
||||
xai_grok_tools::computer::local::LocalTerminalBackend::with_persistent_shell(),
|
||||
xai_grok_tools::computer::local::LocalTerminalBackend::new(),
|
||||
)
|
||||
}
|
||||
fn registry_builder(&self) -> ToolRegistryBuilder {
|
||||
|
|
@ -497,6 +497,10 @@ fn build_proxy_headers(base_url: &str) -> indexmap::IndexMap<String, String> {
|
|||
format!("xai-grok-workspace/{version}"),
|
||||
);
|
||||
headers.insert("x-grok-client-version".to_string(), version.to_string());
|
||||
headers.insert(
|
||||
"x-grok-client-identifier".to_string(),
|
||||
std::env::var("GROK_CLIENT_NAME").unwrap_or_else(|_| "grok-shell".to_string()),
|
||||
);
|
||||
if base_url.contains("cli-chat-proxy") || base_url.contains("chat-proxy") {
|
||||
headers.insert("X-XAI-Token-Auth".to_string(), "xai-grok-cli".to_string());
|
||||
headers.insert(
|
||||
|
|
@ -518,6 +522,9 @@ fn build_web_fetch_config() -> xai_grok_tools::implementations::grok_build::web_
|
|||
if let Ok(proxy) = std::env::var("GROK_WEB_FETCH_PROXY") {
|
||||
params.proxy_endpoint = Some(proxy);
|
||||
}
|
||||
if xai_grok_config::env_bool("GROK_WEB_FETCH_ALLOW_LOCAL") == Some(true) {
|
||||
params.allow_local = Some(true);
|
||||
}
|
||||
WebFetchConfig::Enabled { params }
|
||||
}
|
||||
fn default_web_search_model() -> String {
|
||||
|
|
|
|||
Loading…
Reference in a new issue