diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 80f0d68d2a..553bb255d1 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -169,6 +169,7 @@ pub(crate) async fn run_permission_request_hooks( run_id_suffix: run_id_suffix.to_string(), command: payload.command, description: payload.description, + permission_suggestions: payload.permission_suggestions, }; let preview_runs = sess.hooks().preview_permission_request(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index bb585cbc95..c67f3ec314 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -389,6 +389,7 @@ impl NetworkApprovalService { tool_name: "Bash".to_string(), command, description: Some(format!("network-access {target}")), + permission_suggestions: Vec::new(), }, ) .await diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7d3be77719..d2f86255db 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -368,7 +368,7 @@ impl ToolOrchestrator { where T: ToolRuntime, { - if let Some(permission_request) = tool.permission_request_payload(req) { + if let Some(permission_request) = tool.permission_request_payload(req, &approval_ctx) { match run_permission_request_hooks( approval_ctx.session, approval_ctx.turn, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index c6aff669f1..155a4ff766 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -30,8 +30,10 @@ use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::exec_policy_permission_suggestions; use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; +use codex_hooks::PermissionSuggestionDestination; use codex_network_proxy::NetworkProxy; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::PermissionProfile; @@ -199,11 +201,21 @@ impl Approvable for ShellRuntime { Some(req.exec_approval_requirement.clone()) } - fn permission_request_payload(&self, req: &ShellRequest) -> Option { + fn permission_request_payload( + &self, + req: &ShellRequest, + _approval_ctx: &ApprovalCtx<'_>, + ) -> Option { + let permission_suggestions = exec_policy_permission_suggestions( + req.exec_approval_requirement + .proposed_execpolicy_amendment(), + &[PermissionSuggestionDestination::UserSettings], + ); Some(PermissionRequestPayload { tool_name: "Bash".to_string(), command: req.hook_command.clone(), description: req.justification.clone(), + permission_suggestions, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 836cd2a114..bf0f2cb4eb 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -403,6 +403,7 @@ impl CoreShellActionProvider { tool_name: "Bash".to_string(), command: codex_shell_command::parse_command::shlex_join(&command), description: None, + permission_suggestions: Vec::new(), }; let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); match run_permission_request_hooks( diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index c217b76750..99ea14a5a5 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -27,12 +27,14 @@ use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; +use crate::tools::sandboxing::exec_policy_permission_suggestions; use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; +use codex_hooks::PermissionSuggestionDestination; use codex_network_proxy::NetworkProxy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; @@ -182,11 +184,18 @@ impl Approvable for UnifiedExecRuntime<'_> { fn permission_request_payload( &self, req: &UnifiedExecRequest, + _approval_ctx: &ApprovalCtx<'_>, ) -> Option { + let permission_suggestions = exec_policy_permission_suggestions( + req.exec_approval_requirement + .proposed_execpolicy_amendment(), + &[PermissionSuggestionDestination::UserSettings], + ); Some(PermissionRequestPayload { tool_name: "Bash".to_string(), command: req.hook_command.clone(), description: req.justification.clone(), + permission_suggestions, }) } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 60303064f3..2e5e37f95b 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -10,6 +10,11 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::state::SessionServices; use crate::tools::network_approval::NetworkApprovalSpec; +use codex_hooks::PermissionSuggestion; +use codex_hooks::PermissionSuggestionBehavior; +use codex_hooks::PermissionSuggestionDestination; +use codex_hooks::PermissionSuggestionRule; +use codex_hooks::PermissionSuggestionType; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; @@ -136,6 +141,29 @@ pub(crate) struct PermissionRequestPayload { pub tool_name: String, pub command: String, pub description: Option, + pub permission_suggestions: Vec, +} + +pub(crate) fn exec_policy_permission_suggestions( + proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>, + destinations: &[PermissionSuggestionDestination], +) -> Vec { + proposed_execpolicy_amendment + .into_iter() + .flat_map(|amendment| { + destinations + .iter() + .cloned() + .map(move |destination| PermissionSuggestion { + suggestion_type: PermissionSuggestionType::AddRules, + rules: vec![PermissionSuggestionRule::PrefixRule { + command: amendment.command.clone(), + }], + behavior: PermissionSuggestionBehavior::Allow, + destination, + }) + }) + .collect() } // Specifies what tool orchestrator should do with a given tool call. @@ -282,7 +310,11 @@ pub(crate) trait Approvable { /// Return hook input for approval-time policy hooks when this runtime wants /// hook evaluation to run before guardian or user approval. - fn permission_request_payload(&self, _req: &Req) -> Option { + fn permission_request_payload( + &self, + _req: &Req, + _approval_ctx: &ApprovalCtx<'_>, + ) -> Option { None } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 4a4dac3e81..f5ed4d1850 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -1,5 +1,10 @@ use super::*; use crate::sandboxing::SandboxPermissions; +use codex_hooks::PermissionSuggestion; +use codex_hooks::PermissionSuggestionDestination; +use codex_hooks::PermissionSuggestionRule; +use codex_hooks::PermissionSuggestionType; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::NetworkAccess; use pretty_assertions::assert_eq; @@ -108,3 +113,31 @@ fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() { SandboxOverride::BypassSandboxFirstAttempt ); } + +#[test] +fn command_approval_execpolicy_amendment_maps_to_user_settings_suggestion() { + let suggestions = exec_policy_permission_suggestions( + Some(&ExecPolicyAmendment::new(vec![ + "rm".to_string(), + "-rf".to_string(), + "node_modules".to_string(), + ])), + &[PermissionSuggestionDestination::UserSettings], + ); + + assert_eq!( + suggestions, + vec![PermissionSuggestion { + suggestion_type: PermissionSuggestionType::AddRules, + rules: vec![PermissionSuggestionRule::PrefixRule { + command: vec![ + "rm".to_string(), + "-rf".to_string(), + "node_modules".to_string(), + ], + }], + behavior: PermissionSuggestionBehavior::Allow, + destination: PermissionSuggestionDestination::UserSettings, + }] + ); +} diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index ee63ca77b8..5065de205d 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -521,6 +521,7 @@ fn assert_permission_request_hook_input( assert!(hook_input.get("justification").is_none()); assert!(hook_input.get("host").is_none()); assert!(hook_input.get("protocol").is_none()); + assert!(hook_input.get("permission_suggestions").is_none()); } fn assert_single_permission_request_hook_input( @@ -534,6 +535,13 @@ fn assert_single_permission_request_hook_input( Ok(hook_inputs) } +fn assert_permission_request_hook_suggestions(hook_input: &Value, expected: Value) { + assert_eq!( + hook_input["permission_suggestions"], expected, + "permission request hook should include expected suggestions" + ); +} + fn read_post_tool_use_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("post_tool_use_hook_log.jsonl")) .context("read post tool use hook log")? @@ -1203,11 +1211,26 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() -> "approved command should remove marker file" ); - let hook_inputs = assert_single_permission_request_hook_input( - test.codex_home_path(), - &command, - /*description*/ None, - )?; + let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PermissionRequest"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert_eq!(hook_inputs[0]["tool_input"]["description"], Value::Null); + assert_permission_request_hook_suggestions( + &hook_inputs[0], + serde_json::json!([ + { + "type": "addRules", + "rules": [{ + "type": "prefixRule", + "command": ["rm", "-f", marker.display().to_string()], + }], + "behavior": "allow", + "destination": "userSettings", + } + ]), + ); assert!( hook_inputs[0].get("tool_use_id").is_none(), "PermissionRequest input should not include a tool_use_id", @@ -1293,11 +1316,26 @@ async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> { "approved exec command should remove marker file" ); - assert_single_permission_request_hook_input( - test.codex_home_path(), - &command, - Some(justification), - )?; + let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PermissionRequest"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert_eq!(hook_inputs[0]["tool_input"]["description"], justification); + assert_permission_request_hook_suggestions( + &hook_inputs[0], + serde_json::json!([ + { + "type": "addRules", + "rules": [{ + "type": "prefixRule", + "command": ["rm", "-f", marker.display().to_string()], + }], + "behavior": "allow", + "destination": "userSettings", + } + ]), + ); Ok(()) } @@ -1442,11 +1480,180 @@ allow_local_binding = true "expected the network approval hook to bypass the approval prompt" ); - assert_single_permission_request_hook_input( - test.codex_home_path(), - command, - Some("network-access http://codex-network-test.invalid:80"), + let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PermissionRequest"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert_eq!( + hook_inputs[0]["tool_input"]["description"], + "network-access http://codex-network-test.invalid:80" + ); + assert!(hook_inputs[0].get("permission_suggestions").is_none()); + + test.codex.submit(Op::Shutdown {}).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ShutdownComplete) + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_request_hook_sees_exec_command_network_retry_suggestions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + fs::write( + home.path().join("config.toml"), + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] +enabled = true +mode = "limited" +allow_local_binding = true +"#, )?; + let call_id = "permissionrequest-exec-network-approval"; + let command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=2).read().decode(errors='replace'))""#; + let args = serde_json::json!({ + "cmd": command, + "login": true, + "sandbox_permissions": "require_escalated", + }); + let _responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message( + "msg-1", + "permission request hook allowed exec network access", + ), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let approval_policy = AskForApproval::OnFailure; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let sandbox_policy_for_config = sandbox_policy.clone(); + let test = test_codex() + .with_home(Arc::clone(&home)) + .with_pre_build_hook(|home| { + if let Err(error) = install_allow_permission_request_hook(home) { + panic!("failed to write permission request hook test fixture: {error}"); + } + }) + .with_config(move |config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + let layers = config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .cloned() + .collect(); + let mut requirements = config.config_layer_stack.requirements().clone(); + requirements.network = Some(Sourced::new( + NetworkConstraints { + enabled: Some(true), + allow_local_binding: Some(true), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )); + let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); + requirements_toml.network = Some(NetworkRequirementsToml { + enabled: Some(true), + allow_local_binding: Some(true), + ..Default::default() + }); + config.config_layer_stack = + ConfigLayerStack::new(layers, requirements, requirements_toml) + .expect("rebuild config layer stack with network requirements"); + }) + .build(&server) + .await?; + + test.submit_turn_with_policies( + "run the exec command after network hook approval", + approval_policy, + sandbox_policy, + ) + .await?; + + timeout(Duration::from_secs(10), async { + loop { + if test + .codex_home_path() + .join("permission_request_hook_log.jsonl") + .exists() + { + break; + } + sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("expected exec network approval hook to run"); + + assert!( + timeout( + Duration::from_secs(2), + wait_for_event(&test.codex, |event| matches!( + event, + EventMsg::ExecApprovalRequest(_) + )) + ) + .await + .is_err(), + "expected the exec network approval hook to bypass the approval prompt" + ); + + let hook_inputs = read_permission_request_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PermissionRequest"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert_eq!( + hook_inputs[0]["tool_input"]["description"], + "Network access to \"codex-network-test.invalid\" is blocked by policy." + ); + assert!(hook_inputs[0].get("permission_suggestions").is_none()); test.codex.submit(Op::Shutdown {}).await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json index 744ab0cf95..9e0bbce86f 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.input.schema.json @@ -22,6 +22,79 @@ "command" ], "type": "object" + }, + "PermissionSuggestion": { + "properties": { + "behavior": { + "$ref": "#/definitions/PermissionSuggestionBehavior" + }, + "destination": { + "$ref": "#/definitions/PermissionSuggestionDestination" + }, + "rules": { + "items": { + "$ref": "#/definitions/PermissionSuggestionRule" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/PermissionSuggestionType" + } + }, + "required": [ + "behavior", + "destination", + "rules", + "type" + ], + "type": "object" + }, + "PermissionSuggestionBehavior": { + "enum": [ + "allow", + "deny", + "ask" + ], + "type": "string" + }, + "PermissionSuggestionDestination": { + "enum": [ + "session", + "projectSettings", + "userSettings" + ], + "type": "string" + }, + "PermissionSuggestionRule": { + "oneOf": [ + { + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "prefixRule" + ], + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "type": "object" + } + ] + }, + "PermissionSuggestionType": { + "enum": [ + "addRules" + ], + "type": "string" } }, "properties": { @@ -45,6 +118,12 @@ ], "type": "string" }, + "permission_suggestions": { + "items": { + "$ref": "#/definitions/PermissionSuggestion" + }, + "type": "array" + }, "session_id": { "type": "string" }, diff --git a/codex-rs/hooks/src/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index c6b58b86f1..d2652bb9b3 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -30,6 +30,48 @@ use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PermissionSuggestion { + #[serde(rename = "type")] + pub suggestion_type: PermissionSuggestionType, + pub rules: Vec, + pub behavior: PermissionSuggestionBehavior, + pub destination: PermissionSuggestionDestination, +} + +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum PermissionSuggestionType { + AddRules, +} + +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PermissionSuggestionBehavior { + Allow, + Deny, + Ask, +} + +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub enum PermissionSuggestionDestination { + #[serde(rename = "session")] + Session, + #[serde(rename = "projectSettings")] + ProjectSettings, + #[serde(rename = "userSettings")] + UserSettings, +} + +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PermissionSuggestionRule { + PrefixRule { command: Vec }, +} #[derive(Debug, Clone)] pub struct PermissionRequestRequest { @@ -43,6 +85,7 @@ pub struct PermissionRequestRequest { pub run_id_suffix: String, pub command: String, pub description: Option, + pub permission_suggestions: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -179,6 +222,8 @@ fn build_command_input(request: &PermissionRequestRequest) -> PermissionRequestC command: request.command.clone(), description: request.description.clone(), }, + permission_suggestions: (!request.permission_suggestions.is_empty()) + .then_some(request.permission_suggestions.clone()), } } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index c8358c678c..75b87d2566 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -8,6 +8,11 @@ mod types; pub use events::permission_request::PermissionRequestDecision; pub use events::permission_request::PermissionRequestOutcome; pub use events::permission_request::PermissionRequestRequest; +pub use events::permission_request::PermissionSuggestion; +pub use events::permission_request::PermissionSuggestionBehavior; +pub use events::permission_request::PermissionSuggestionDestination; +pub use events::permission_request::PermissionSuggestionRule; +pub use events::permission_request::PermissionSuggestionType; pub use events::post_tool_use::PostToolUseOutcome; pub use events::post_tool_use::PostToolUseRequest; pub use events::pre_tool_use::PreToolUseOutcome; diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d7693ac704..34a6a993cb 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -12,6 +12,8 @@ use serde_json::Value; use std::path::Path; use std::path::PathBuf; +use crate::events::permission_request::PermissionSuggestion; + const GENERATED_DIR: &str = "generated"; const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json"; const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json"; @@ -263,6 +265,8 @@ pub(crate) struct PermissionRequestCommandInput { #[schemars(schema_with = "permission_request_tool_name_schema")] pub tool_name: String, pub tool_input: PermissionRequestToolInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_suggestions: Option>, } #[derive(Debug, Clone, Serialize, JsonSchema)]