35
mod instructions;
36
mod mcp;
37
mod models;
38
+mod permissions;
39
mod provider;
40
mod settings;
41
mod setup;
63
AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
64
ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
65
ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
65
- LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest,
66
- PromptResponse, SessionCapabilities, SessionConfigOption, SessionConfigOptionCategory,
67
- SessionConfigSelectOption, SessionConfigValueId, SessionForkCapabilities, SessionId,
68
- SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
69
- SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallStatus, ToolCallUpdate,
70
- ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
66
+ LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
67
+ PermissionOptionKind, PromptRequest, PromptResponse, RequestPermissionOutcome,
68
+ RequestPermissionRequest, SessionCapabilities, SessionConfigOption,
69
+ SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
70
+ SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
71
+ SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
72
+ ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
73
};
74
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
75
use onde::inference::{ChatEngine, GgufModelConfig};
232
/// cap tool-call loops so a confused model can't spin forever
233
const MAX_TOOL_ROUNDS: usize = 10;
234
235
+/// Outcome of asking the client for permission to run one tool call.
236
+enum PermissionVerdict {
237
+ /// Run the tool.
238
+ Approved,
239
+ /// Skip the tool; the string becomes its tool result so the model adapts.
240
+ Denied(String),
241
+ /// The client cancelled the turn while the request was pending; stop the
242
+ /// whole prompt with `StopReason::Cancelled` instead of burning rounds.
243
+ TurnCancelled,
244
+}
245
+
246
+/// Display kind for the permission dialog, so editors can show a fitting icon.
247
+fn tool_kind_for(tool_name: &str) -> ToolKind {
248
+ match tool_name {
249
+ "edit_file" | "multi_edit" | "create_file" | "create_directory" | "remember" => {
250
+ ToolKind::Edit
251
+ }
252
+ "delete_file" => ToolKind::Delete,
253
+ "run_command" => ToolKind::Execute,
254
+ "read_file" | "list_directory" => ToolKind::Read,
255
+ "search_files" | "glob" => ToolKind::Search,
256
+ "read_website" => ToolKind::Fetch,
257
+ "write_todos" => ToolKind::Think,
258
+ _ => ToolKind::Other,
259
+ }
260
+}
261
+
262
/// Shown when a siGit Code Cloud tier is selected without a signed-in account.
263
const CLOUD_LOGIN_PROMPT: &str = "siGit Code Cloud needs an account. Sign in with \
264
`/login <email> <password>` (or the Authenticate button), then pick the tier again. \
379
startup_model_name: String,
380
/// for download-progress polling
381
startup_model_id: String,
382
+ /// Serializes turn-affecting handlers (prompt, session lifecycle, config
383
+ /// changes). They run in `cx.spawn`ed tasks so the JSON-RPC dispatch loop
384
+ /// stays free to route client responses (e.g. permission answers) mid-turn;
385
+ /// this lock reproduces the strict ordering the dispatch loop used to give
386
+ /// them for free.
387
+ turn_lock: Arc<tokio::sync::Mutex<()>>,
388
}
389
390
impl SiGitAgent {
410
startup_needs_download,
411
startup_model_name,
412
startup_model_id,
413
+ turn_lock: Arc::new(tokio::sync::Mutex::new(())),
414
}
415
}
416
763
AvailableCommand::new("logout", "Sign out of siGit Code Cloud"),
764
AvailableCommand::new("whoami", "Show the signed-in account"),
765
AvailableCommand::new("reload", "Re-sync sign-in and model state"),
766
+ with_hint(
767
+ "plan",
768
+ "Plan mode: research only, no edits or commands",
769
+ "on|off (optional)",
770
+ ),
771
+ AvailableCommand::new("permissions", "Show the tool permission policy"),
772
AvailableCommand::new("clear", "Wipe the conversation history"),
773
AvailableCommand::new("status", "Show engine status"),
774
];
949
*guard = Some(args.cwd.clone());
950
}
951
952
+ // A reloaded session starts fresh: grants and plan mode from the
953
+ // previous life of this session id must not carry over.
954
+ permissions::reset_session(&args.session_id.to_string());
955
+
956
// tool calls use relative paths, so we need to match the editor's cwd
957
if args.cwd.is_dir()
958
&& let Err(err) = std::env::set_current_dir(&args.cwd)
1302
tc.arguments.chars().take(120).collect::<String>()
1303
);
1304
1259
- let output = tools::execute_tool(&tc.name, &tc.arguments).await;
1305
+ // Permission gate: read-only tools pass straight through; a
1306
+ // mutating tool consults policy and may ask the client.
1307
+ let output = match permissions::decision_for(&session_id.to_string(), &tc.name) {
1308
+ permissions::Decision::Allow => {
1309
+ tools::execute_tool(&tc.name, &tc.arguments).await
1310
+ }
1311
+ permissions::Decision::Deny(reason) => {
1312
+ log::info!(" ✗ {} denied by policy", tc.name);
1313
+ reason
1314
+ }
1315
+ permissions::Decision::Ask => {
1316
+ match self
1317
+ .request_tool_permission(cx, &session_id, &tc.name, &tc.arguments)
1318
+ .await
1319
+ {
1320
+ PermissionVerdict::Approved => {
1321
+ tools::execute_tool(&tc.name, &tc.arguments).await
1322
+ }
1323
+ PermissionVerdict::Denied(reason) => {
1324
+ log::info!(" ✗ {} denied by user", tc.name);
1325
+ reason
1326
+ }
1327
+ PermissionVerdict::TurnCancelled => {
1328
+ log::info!("prompt({}) cancelled at permission gate", session_id);
1329
+ return Ok(PromptResponse::new(StopReason::Cancelled));
1330
+ }
1331
+ }
1332
+ }
1333
+ };
1334
1335
log::info!(" ← {} chars", output.len());
1336
1397
Ok(PromptResponse::new(StopReason::EndTurn))
1398
}
1399
1400
+ /// Ask the ACP client for permission to run one tool call. Presents
1401
+ /// allow-once / allow-for-session / deny; an "always allow" choice is
1402
+ /// recorded via [`permissions::grant_for_session`]. Only safe to call from
1403
+ /// a spawned task (see the handler registration in `run_acp_server`): the
1404
+ /// dispatch loop must be free to route the client's answer back to us.
1405
+ async fn request_tool_permission(
1406
+ &self,
1407
+ cx: &ConnectionTo<Client>,
1408
+ session_id: &SessionId,
1409
+ tool_name: &str,
1410
+ arguments: &str,
1411
+ ) -> PermissionVerdict {
1412
+ let args_preview: String = arguments.chars().take(120).collect();
1413
+ let title = if args_preview.is_empty() {
1414
+ tool_name.to_string()
1415
+ } else {
1416
+ format!("{tool_name}({args_preview})")
1417
+ };
1418
+
1419
+ let request = RequestPermissionRequest::new(
1420
+ session_id.clone(),
1421
+ ToolCallUpdate::new(
1422
+ format!("perm-{}", uuid::Uuid::new_v4()),
1423
+ ToolCallUpdateFields::new()
1424
+ .title(title)
1425
+ .kind(tool_kind_for(tool_name))
1426
+ .status(ToolCallStatus::Pending),
1427
+ ),
1428
+ vec![
1429
+ PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce),
1430
+ PermissionOption::new(
1431
+ "allow_session",
1432
+ "Allow for this session",
1433
+ PermissionOptionKind::AllowAlways,
1434
+ ),
1435
+ PermissionOption::new("reject_once", "Deny", PermissionOptionKind::RejectOnce),
1436
+ ],
1437
+ );
1438
+
1439
+ match cx.send_request(request).block_task().await {
1440
+ Ok(response) => match response.outcome {
1441
+ RequestPermissionOutcome::Selected(selected) => {
1442
+ match selected.option_id.0.as_ref() {
1443
+ "allow_once" => PermissionVerdict::Approved,
1444
+ "allow_session" => {
1445
+ permissions::grant_for_session(&session_id.to_string(), tool_name);
1446
+ PermissionVerdict::Approved
1447
+ }
1448
+ _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
1449
+ }
1450
+ }
1451
+ RequestPermissionOutcome::Cancelled => PermissionVerdict::TurnCancelled,
1452
+ // The outcome enum is non_exhaustive; treat anything unknown as
1453
+ // a denial rather than running a mutating tool unapproved.
1454
+ _ => PermissionVerdict::Denied(permissions::user_denial(tool_name)),
1455
+ },
1456
+ Err(error) => {
1457
+ log::warn!("permission request for `{tool_name}` failed: {error}");
1458
+ PermissionVerdict::Denied(format!(
1459
+ "`{tool_name}` was not executed: this client could not answer the \
1460
+ permission request ({error}). The user can pre-approve tools in \
1461
+ settings.toml under [permissions], or set SIGIT_PERMISSIONS=allow \
1462
+ for clients without permission support."
1463
+ ))
1464
+ }
1465
+ }
1466
+ }
1467
+
1468
async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
1469
log::info!("cancel requested for session {}", args.session_id);
1470
Ok(())
2119
Whoami,
2120
/// Re-sync session state (auth, backend, picker) without a new session.
2121
Reload,
2122
+ /// Toggle plan mode (read-only research; mutating tools denied with a
2123
+ /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
2124
+ Plan(Option<bool>),
2125
+ /// Show the effective permission policy for this session.
2126
+ Permissions,
2127
Exit,
2128
Unknown(String),
2129
}
2149
"/logout" => SlashCommand::Logout,
2150
"/whoami" => SlashCommand::Whoami,
2151
"/reload" => SlashCommand::Reload,
2152
+ "/plan" => SlashCommand::Plan(parse_on_off(argument)),
2153
+ "/permissions" => SlashCommand::Permissions,
2154
"/exit" | "/quit" | "/q" => SlashCommand::Exit,
2155
other => SlashCommand::Unknown(other.to_string()),
2156
})
2259
/logout - sign out\n\
2260
/whoami - show the signed-in account\n\
2261
/reload - re-sync sign-in and model state\n\
2262
+ /plan [on|off] - plan mode: research only, no edits or commands\n\
2263
+ /permissions - show the tool permission policy\n\
2264
/clear - wipe conversation history\n\
2265
/status - show engine status\n\
2266
/exit - end this turn",
2269
}
2270
SlashCommand::Clear => {
2271
let cleared = agent.engine.clear_history().await;
2272
+ permissions::reset_session(&session_id.to_string());
2273
agent
2274
.send_assistant_message(
2275
cx,
2278
)
2279
.ok();
2280
}
2281
+ SlashCommand::Plan(value) => {
2282
+ let session_key = session_id.to_string();
2283
+ let enabled = value.unwrap_or_else(|| !permissions::plan_mode(&session_key));
2284
+ permissions::set_plan_mode(&session_key, enabled);
2285
+ let message = if enabled {
2286
+ "Plan mode ON — the agent researches with read-only tools and presents a \
2287
+ plan; edits and commands are blocked until /plan off."
2288
+ } else {
2289
+ "Plan mode OFF — the agent may execute tools again (subject to the \
2290
+ permission policy)."
2291
+ };
2292
+ agent.send_assistant_message(cx, session_id, message).ok();
2293
+ }
2294
+ SlashCommand::Permissions => {
2295
+ let summary = permissions::describe(&session_id.to_string());
2296
+ agent.send_assistant_message(cx, session_id, summary).ok();
2297
+ }
2298
SlashCommand::Status => {
2299
let info = agent.engine.info().await;
2300
let model = info.model_name.as_deref().unwrap_or("(none)");
2846
},
2847
agent_client_protocol::on_receive_request!(),
2848
)
2849
+ // Turn-affecting handlers below run in spawned tasks, serialized by
2850
+ // `turn_lock`, so the dispatch loop stays free to route client
2851
+ // responses (permission answers) while a turn is in flight. Awaiting a
2852
+ // client request from *inside* a handler would deadlock: the dispatch
2853
+ // loop can't read the response while the handler blocks it.
2854
.on_receive_request(
2855
{
2856
let state = Arc::clone(&state);
2857
async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
2684
- handle_response(responder, state.handle_load_session(&cx, req).await)
2858
+ let state = Arc::clone(&state);
2859
+ let task_cx = cx.clone();
2860
+ cx.spawn(async move {
2861
+ let _turn = state.turn_lock.lock().await;
2862
+ handle_response(responder, state.handle_load_session(&task_cx, req).await)
2863
+ })
2864
}
2865
},
2866
agent_client_protocol::on_receive_request!(),
2869
{
2870
let state = Arc::clone(&state);
2871
async move |req: ForkSessionRequest, responder, cx: ConnectionTo<Client>| {
2693
- handle_response(responder, state.handle_fork_session(&cx, req).await)
2872
+ let state = Arc::clone(&state);
2873
+ let task_cx = cx.clone();
2874
+ cx.spawn(async move {
2875
+ let _turn = state.turn_lock.lock().await;
2876
+ handle_response(responder, state.handle_fork_session(&task_cx, req).await)
2877
+ })
2878
}
2879
},
2880
agent_client_protocol::on_receive_request!(),
2883
{
2884
let state = Arc::clone(&state);
2885
async move |req: NewSessionRequest, responder, cx: ConnectionTo<Client>| {
2702
- handle_response(responder, state.handle_new_session(&cx, req).await)
2886
+ let state = Arc::clone(&state);
2887
+ let task_cx = cx.clone();
2888
+ cx.spawn(async move {
2889
+ let _turn = state.turn_lock.lock().await;
2890
+ handle_response(responder, state.handle_new_session(&task_cx, req).await)
2891
+ })
2892
}
2893
},
2894
agent_client_protocol::on_receive_request!(),
2897
{
2898
let state = Arc::clone(&state);
2899
async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
2711
- handle_response(responder, state.handle_prompt(&cx, req).await)
2900
+ let state = Arc::clone(&state);
2901
+ let task_cx = cx.clone();
2902
+ cx.spawn(async move {
2903
+ let _turn = state.turn_lock.lock().await;
2904
+ handle_response(responder, state.handle_prompt(&task_cx, req).await)
2905
+ })
2906
}
2907
},
2908
agent_client_protocol::on_receive_request!(),
2913
async move |req: SetSessionConfigOptionRequest,
2914
responder,
2915
cx: ConnectionTo<Client>| {
2722
- handle_response(
2723
- responder,
2724
- state.handle_set_session_config_option(&cx, req).await,
2725
- )
2916
+ let state = Arc::clone(&state);
2917
+ let task_cx = cx.clone();
2918
+ cx.spawn(async move {
2919
+ let _turn = state.turn_lock.lock().await;
2920
+ handle_response(
2921
+ responder,
2922
+ state.handle_set_session_config_option(&task_cx, req).await,
2923
+ )
2924
+ })
2925
}
2926
},
2927
agent_client_protocol::on_receive_request!(),