Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/apps/cli/src/agent/runtime_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2549,6 +2549,7 @@ mod tests {
subagent_type: "general".to_string(),
}),
display_metadata: serde_json::Map::new(),
permission_mode: None,
},
};
project_routed_permission_event(&mut permission, "root");
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/chat_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,7 @@ mod tests {
subagent_type: "Explore".to_string(),
}),
display_metadata: serde_json::Map::new(),
permission_mode: None,
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/apps/cli/src/dispatch/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2181,6 +2181,7 @@ mod tests {
},
delegation: None,
display_metadata: Map::new(),
permission_mode: None,
}
}

Expand Down Expand Up @@ -2477,14 +2478,14 @@ mod tests {
.save_permission_answer(
"job-permission",
&permission.request_id,
PermissionReply::Once,
PermissionReply::Once { feedback: None },
)
.expect("first answer"));
assert!(store
.save_permission_answer(
"job-permission",
&permission.request_id,
PermissionReply::Once,
PermissionReply::Once { feedback: None },
)
.expect("retry pending answer"));
let answer = store
Expand All @@ -2499,14 +2500,14 @@ mod tests {
.save_permission_answer(
"job-permission",
&permission.request_id,
PermissionReply::Once,
PermissionReply::Once { feedback: None },
)
.expect("retry resolved answer"));
assert!(store
.save_permission_answer(
"job-permission",
&permission.request_id,
PermissionReply::Always,
PermissionReply::Always { feedback: None },
)
.is_err());
}
Expand Down
5 changes: 4 additions & 1 deletion src/apps/cli/src/modes/exec/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,10 @@ impl ExecMode {
) -> Self {
let approval_mode = match runtime.approval_policy() {
crate::runtime::approval::CliApprovalPolicy::Auto => ExecApprovalMode::Auto,
crate::runtime::approval::CliApprovalPolicy::Ask
// Non-interactive execution cannot confirm AI-judge escalations,
// so requests that reach the user prompt are rejected.
crate::runtime::approval::CliApprovalPolicy::AiAuto
| crate::runtime::approval::CliApprovalPolicy::Ask
| crate::runtime::approval::CliApprovalPolicy::DisableAuto
| crate::runtime::approval::CliApprovalPolicy::Reject => ExecApprovalMode::Reject,
};
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/modes/exec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ fn delegated_permission_request() -> PermissionRequest {
subagent_type: "Explore".to_string(),
}),
display_metadata: serde_json::Map::new(),
permission_mode: None,
}
}

Expand Down
14 changes: 12 additions & 2 deletions src/apps/cli/src/peer_host/commands/permission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,18 @@ use crate::peer_host::state::PeerHostState;

fn permission_reply(request: &Value) -> Result<PermissionReply, String> {
match get_string(request, "reply")?.as_str() {
"once" => Ok(PermissionReply::Once),
"always" => Ok(PermissionReply::Always),
"once" => Ok(PermissionReply::Once {
feedback: request
.get("feedback")
.and_then(Value::as_str)
.map(str::to_string),
}),
"always" => Ok(PermissionReply::Always {
feedback: request
.get("feedback")
.and_then(Value::as_str)
.map(str::to_string),
}),
"reject" => Ok(PermissionReply::Reject {
feedback: request
.get("feedback")
Expand Down
1 change: 1 addition & 0 deletions src/apps/cli/src/peer_host/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,7 @@ mod tests {
subagent_type: "Explore".to_string(),
}),
display_metadata: serde_json::Map::new(),
permission_mode: None,
}
}

Expand Down
35 changes: 31 additions & 4 deletions src/apps/cli/src/runtime/approval.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY;
use bitfun_agent_runtime::sdk::{PermissionRequest, AUTO_APPROVE_ASK_CONTEXT_KEY};
use bitfun_agent_runtime::permission::{
AI_AUTO_APPROVE_ASK_CONTEXT_KEY, AUTO_APPROVE_ASK_CONTEXT_KEY, PERMISSION_MODE_CONTEXT_KEY,
};
use bitfun_agent_runtime::sdk::PermissionRequest;
use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY;
use bitfun_runtime_ports::PermissionMode;
use serde_json::{Map, Value};
Expand All @@ -12,6 +14,9 @@ pub(crate) enum CliApprovalPolicy {
DisableAuto,
Reject,
Auto,
/// Let the fast-model permission judge decide: safe requests auto-approve,
/// critical-risk requests are rejected, the rest escalate to the user.
AiAuto,
}

/// Build invocation-scoped approval metadata consumed by the shared Runtime.
Expand All @@ -30,7 +35,7 @@ pub(crate) fn approval_metadata(approval_policy: CliApprovalPolicy) -> Map<Strin
);
}
let auto_approve_ask = match approval_policy {
CliApprovalPolicy::Ask => None,
CliApprovalPolicy::Ask | CliApprovalPolicy::AiAuto => None,
CliApprovalPolicy::DisableAuto | CliApprovalPolicy::Reject => Some(false),
CliApprovalPolicy::Auto => Some(true),
};
Expand All @@ -55,6 +60,18 @@ pub(crate) fn approval_metadata(approval_policy: CliApprovalPolicy) -> Map<Strin
),
);
}
if matches!(approval_policy, CliApprovalPolicy::AiAuto) {
// The AI mode resolves through the same single value as every other
// surface. The legacy flag is kept so older runtimes still honor it.
metadata.insert(
PERMISSION_MODE_CONTEXT_KEY.to_string(),
Value::String(PermissionMode::AiAutoApprove.as_str().to_string()),
);
metadata.insert(
AI_AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(),
Value::Bool(true),
);
}
metadata
}

Expand All @@ -75,7 +92,7 @@ mod tests {
use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY;
use bitfun_agent_runtime::sdk::{
PermissionDelegationContext, PermissionRequest, PermissionRequestSource,
PermissionRequestSourceKind, AUTO_APPROVE_ASK_CONTEXT_KEY,
PermissionRequestSourceKind, AI_AUTO_APPROVE_ASK_CONTEXT_KEY, AUTO_APPROVE_ASK_CONTEXT_KEY,
};
use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY;
use serde_json::Map;
Expand Down Expand Up @@ -104,6 +121,7 @@ mod tests {
subagent_type: "Explore".to_string(),
}),
display_metadata: Map::new(),
permission_mode: None,
}
}

Expand Down Expand Up @@ -152,6 +170,15 @@ mod tests {
approval_metadata(CliApprovalPolicy::DisableAuto).get(AUTO_APPROVE_ASK_CONTEXT_KEY),
Some(&serde_json::Value::Bool(false))
);

let ai_auto = approval_metadata(CliApprovalPolicy::AiAuto);
assert_eq!(
ai_auto.get(AI_AUTO_APPROVE_ASK_CONTEXT_KEY),
Some(&serde_json::Value::Bool(true))
);
// AI judging keeps interactive escalation possible.
assert!(ai_auto.get(USER_INPUT_AVAILABLE_CONTEXT_KEY).is_none());
assert!(ai_auto.get(AUTO_APPROVE_ASK_CONTEXT_KEY).is_none());
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion src/apps/cli/src/shared_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,7 @@ mod tests {
subagent_type: "general".to_string(),
}),
display_metadata: serde_json::Map::new(),
permission_mode: None,
}
}

Expand Down Expand Up @@ -1861,7 +1862,7 @@ mod tests {
},
PermissionRequestEvent::Replied {
request_id: request.request_id,
reply: bitfun_agent_runtime::sdk::PermissionReply::Once,
reply: bitfun_agent_runtime::sdk::PermissionReply::Once { feedback: None },
source: PermissionReplySource::User,
},
];
Expand Down
7 changes: 4 additions & 3 deletions src/apps/cli/src/ui/permission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ impl PermissionPrompt {
}
KeyCode::Esc => PermissionAction::Reply(PermissionReply::Reject { feedback: None }),
KeyCode::Enter => match self.selected_option {
0 => PermissionAction::Reply(PermissionReply::Once),
1 => PermissionAction::Reply(PermissionReply::Always),
0 => PermissionAction::Reply(PermissionReply::Once { feedback: None }),
1 => PermissionAction::Reply(PermissionReply::Always { feedback: None }),
_ => {
self.editing_reject_feedback = true;
PermissionAction::None
Expand Down Expand Up @@ -310,6 +310,7 @@ mod tests {
},
delegation: None,
display_metadata: Map::new(),
permission_mode: None,
}
}

Expand All @@ -320,7 +321,7 @@ mod tests {

assert_eq!(
prompt.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
PermissionAction::Reply(PermissionReply::Always)
PermissionAction::Reply(PermissionReply::Always { feedback: None })
);
}

Expand Down
50 changes: 44 additions & 6 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,7 @@ pub struct PermissionAuditPage {
#[serde(rename_all = "camelCase")]
pub struct ProjectPermissionRulesResponse {
pub rules: Vec<PermissionRule>,
pub sensitive_resources: Vec<String>,
pub revision: String,
}

Expand All @@ -942,6 +943,8 @@ pub struct ProjectPermissionRulesResponse {
pub struct SaveProjectPermissionRulesRequest {
pub workspace_id: String,
pub rules: Vec<PermissionRule>,
#[serde(default)]
pub sensitive_resources: Vec<String>,
pub revision: String,
}

Expand Down Expand Up @@ -1131,6 +1134,20 @@ fn validate_project_permission_rules(rules: &[PermissionRule]) -> Result<(), Str
Ok(())
}

/// Trims and drops empty sensitive-resource markers before they are persisted.
///
/// The UI keeps a draft row the user added but never filled; persisting it
/// would grow the config with markers that can never match anything. The
/// filter is deliberately silent — empty rows are not an error, they are
/// just unfinished input.
fn normalize_sensitive_resources(resources: Vec<String>) -> Vec<String> {
resources
.into_iter()
.map(|resource| resource.trim().to_string())
.filter(|resource| !resource.is_empty())
.collect()
}

#[tauri::command]
pub async fn get_project_permission_rules(
state: State<'_, AppState>,
Expand All @@ -1139,15 +1156,15 @@ pub async fn get_project_permission_rules(
let target =
project_permission_config_target_for_workspace(&state, &request.workspace_id).await?;
let content = read_project_permission_config_content(&state, &target).await?;
let rules = content
let config = content
.as_deref()
.map(deserialize_project_permission_config)
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or_default()
.rules;
.unwrap_or_default();
Ok(ProjectPermissionRulesResponse {
rules,
rules: config.rules,
sensitive_resources: config.sensitive_resources,
revision: project_permission_rules_revision(content.as_deref()),
})
}
Expand All @@ -1158,6 +1175,7 @@ pub async fn save_project_permission_rules(
request: SaveProjectPermissionRulesRequest,
) -> Result<ProjectPermissionRulesResponse, String> {
validate_project_permission_rules(&request.rules)?;
let sensitive_resources = normalize_sensitive_resources(request.sensitive_resources.clone());

let target =
project_permission_config_target_for_workspace(&state, &request.workspace_id).await?;
Expand All @@ -1173,12 +1191,14 @@ pub async fn save_project_permission_rules(
"{}\n",
serde_json::to_string_pretty(&ProjectPermissionConfig {
rules: request.rules.clone(),
sensitive_resources: sensitive_resources.clone(),
})
.map_err(|error| format!("Failed to serialize project permission rules: {error}"))?
);
write_project_permission_config_content(&state, &target, &content).await?;
Ok(ProjectPermissionRulesResponse {
rules: request.rules,
sensitive_resources,
revision: project_permission_rules_revision(Some(&content)),
})
}
Expand Down Expand Up @@ -1270,8 +1290,12 @@ pub enum PermissionReplyKind {

fn permission_reply(request: PermissionResponseRequest) -> PermissionReply {
match request.reply {
PermissionReplyKind::Once => PermissionReply::Once,
PermissionReplyKind::Always => PermissionReply::Always,
PermissionReplyKind::Once => PermissionReply::Once {
feedback: request.feedback,
},
PermissionReplyKind::Always => PermissionReply::Always {
feedback: request.feedback,
},
PermissionReplyKind::Reject => PermissionReply::Reject {
feedback: request.feedback,
},
Expand Down Expand Up @@ -3643,6 +3667,20 @@ mod tests {
use bitfun_product_domains::tool_permissions::{PermissionEffect, PermissionRule};
use serde_json::json;

#[test]
fn sensitive_resources_normalization_trims_and_drops_empty_markers() {
assert_eq!(
normalize_sensitive_resources(vec![
"secrets/".to_string(),
" ".to_string(),
"".to_string(),
" .cursorrules ".to_string(),
]),
vec!["secrets/".to_string(), ".cursorrules".to_string()]
);
assert!(normalize_sensitive_resources(Vec::new()).is_empty());
}

#[tokio::test]
async fn remote_mode_catalog_never_scans_an_absolute_desktop_host_path() {
let desktop_host_path = std::env::current_dir().expect("desktop host working directory");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ const ZH_CN_MESSAGES: [string, string][] = [
['remote.permissions.askDescription', '高风险操作会等待你确认。'],
['remote.permissions.auto', '自动批准'],
['remote.permissions.autoDescription', '自动通过原本需要确认的操作。'],
['remote.permissions.aiAuto', 'AI 自动批准'],
['remote.permissions.aiAutoDescription', '由快速模型判断安全性:安全操作自动通过,极高风险直接拒绝,其余等待你确认。'],
['remote.permissions.fullAccess', '完全访问'],
['remote.permissions.fullAccessDescription', '允许所有工具操作,不再请求确认。'],
['remote.permissions.loading', '正在读取桌面权限设置…'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export interface CreateSessionOptions {
modelId?: string;
}

export type RemotePermissionMode = 'ask' | 'auto' | 'full_access';
export type RemotePermissionMode = 'ask' | 'auto' | 'ai_auto' | 'full_access';

export interface PairRequest {
public_key: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ export struct RemoteControlSettingsSheet {
RemoteI18n.t('remote.permissions.autoDescription')
)
Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 })
this.PermissionModeRow(
'ai_auto',
RemoteI18n.t('remote.permissions.aiAuto'),
RemoteI18n.t('remote.permissions.aiAutoDescription')
)
Divider().strokeWidth(1).color(LINE).margin({ left: 18, right: 18 })
this.PermissionModeRow(
'full_access',
RemoteI18n.t('remote.permissions.fullAccess'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ fn protocol_round_trips_reviewed_permission_and_user_input_operations() {
RuntimeIpcOperation::RespondPermission {
session_id: "session-1".to_string(),
request_id: "permission-1".to_string(),
reply: PermissionReply::Once,
reply: PermissionReply::Once { feedback: None },
},
RuntimeIpcOperation::SubmitUserAnswers {
request: RuntimeUserAnswersRequest {
Expand Down
Loading