diff --git a/README.md b/README.md index 9a185ec05..0703a48c7 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ The service is standalone and independent of Electron. Freshclaude, Freshcodex, and Freshopencode share a chat interface with tool output, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Changes apply to your next message; the picker remembers recent choices for each project. +In Freshclaude, “Allow and don't ask again” saves the rule Claude suggests, listed on the card before you click (project rules go to `.claude/settings.local.json` in the project), and when Claude finishes planning it shows the plan with choices to approve it, approve and auto-accept edits, approve and stop asking for permission, or keep planning. In Freshcodex, “Always allow” lasts until the pane reloads. + You can queue follow-up messages while an agent works. They run one at a time, and the queue stays available if the session disconnects or ends. Expand the queue to read or cancel individual messages. Codex permission settings control when it asks for approval; “Never ask” does not change the session’s file or network access limits. ### Session switching and recovery diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 57a4fac54..fccc9e444 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -520,12 +520,17 @@ struct PendingApprovalEntry { blocked_path: Option, decision_reason: Option, input: Option, + /// The CLI's own suggestions (what "Allow and don't ask again" sends back), + /// normalized to the strict contract shape on entry + /// ([`crate::claude_permission_update::normalize_permission_updates`]); `None` + /// when the request offered none that survive. + suggestions: Option>, } impl PendingApprovalEntry { /// The `.strict()` contract shape the Task 3 snapshot overlay serializes - /// (`{requestId, toolName?, toolUseID?, blockedPath?, decisionReason?, input?}` — - /// keys omitted when absent, never null). + /// (`{requestId, toolName?, toolUseID?, blockedPath?, decisionReason?, input?, + /// suggestions?}` — keys omitted when absent, never null). fn to_json(&self) -> Value { let mut obj = Map::new(); obj.insert("requestId".to_string(), json!(self.request_id)); @@ -544,6 +549,9 @@ impl PendingApprovalEntry { if let Some(input) = &self.input { obj.insert("input".to_string(), input.clone()); } + if let Some(suggestions) = &self.suggestions { + obj.insert("suggestions".to_string(), Value::Array(suggestions.clone())); + } Value::Object(obj) } } @@ -578,11 +586,10 @@ struct ClaudeSession { /// `handle_interrupt` MUST address the sidecar with this id, never the map key. sidecar_session_id: String, /// Best-effort copy of the durable Claude UUID, recorded from `sdk.session.init` - /// by the stdout consumer. Nothing in production reads it: attach/eviction resolve - /// durable ids through [`FreshClaudeState::cli_index`], and the snapshot adapter - /// is disk-only. Currently read only by in-crate tests; kept as a diagnostic/ - /// forward slot. - #[allow(dead_code)] + /// by the stdout consumer. Attach/eviction resolve durable ids through + /// [`FreshClaudeState::cli_index`], and the snapshot adapter is disk-only; the + /// settings paths (a settings apply, the CLI's own permission-mode change) read + /// it to write the session's binding row once the session has one. cli_session_id: Option, /// b8ke focused episode-2 round-2 F2: the operation id of the lifecycle /// op that OWNS this runtime's coordinator claim — the create-resume @@ -4787,6 +4794,33 @@ impl FreshClaudeState { ); return; } + // Client-alerts G20: the decision's `updatedPermissions` (the CLI's own + // suggestions the user accepted) reaches the CLI verbatim, so a malformed + // one would fail or misapply the user's choice there. Refuse it with the + // same discipline as above: nothing forwarded, the request stays pending. + // The refusal names the failing update by position and variant only, so + // neither the log nor the error frame carries a rule's content. + if let Err(refusal) = crate::claude_permission_update::validate_decision(&msg.decision) { + drop(session); + tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.approval_decision_refused", + provider = PROVIDER, + session_id = %session_id, + session_type, + request_id = %request_id, + update_index = refusal.update_index(), + update_type = refusal.update_type(), + "a claude approval decision carried malformed permission updates; nothing was sent" + ); + self.emit_fresh_agent_error( + &session_id, + session_type, + "INVALID_APPROVAL_DECISION", + &format!("Claude approval {request_id} was not sent: {refusal}"), + ); + return; + } // Address the sidecar by ITS id for this session (== the map key for created // sessions; differs for resumed-on-attach sessions, Task 6). let respond_req = json!({ @@ -4977,9 +5011,10 @@ impl FreshClaudeState { /// The session's live pending approvals + questions as overlay JSON (approvals, /// questions) — Task 3's snapshot route consumes this. Entry keys are exactly the /// `.strict()` contract shape (`{requestId, toolName?, toolUseID?, blockedPath?, - /// decisionReason?, input?}` / `{requestId, questions}`, no extra keys, absent keys - /// omitted). `any_id` resolves like every handler here: the map key OR the durable - /// Claude UUID via `cli_index`. An untracked id yields two empty vecs. + /// decisionReason?, input?, suggestions?}` / `{requestId, questions}`, no extra + /// keys, absent keys omitted). `any_id` resolves like every handler here: the map + /// key OR the durable Claude UUID via `cli_index`. An untracked id yields two empty + /// vecs. pub(crate) async fn snapshot_pending_overlay(&self, any_id: &str) -> (Vec, Vec) { let Some(map_key) = self.resolve_session_key(any_id).await else { return (Vec::new(), Vec::new()); @@ -6729,11 +6764,33 @@ impl FreshClaudeState { // mode). The sidecar already runs in it; the session's // settings take it too, so a later configure stating the // same mode applies nothing and the snapshot states it. + // The binding row takes it the way a configure's settings + // do (client-alerts G20), so a resume after a restart + // boots the conversation in the mode the CLI is in. Some("sdk.session.metadata") => { if let Some(mode) = value.get("permissionMode").and_then(Value::as_str) { - if let Some(session) = sessions.lock().await.get_mut(&session_id) { - session.configuration.settings.permission_mode = - Some(mode.to_string()); + let adopted = + sessions.lock().await.get_mut(&session_id).map(|session| { + session.configuration.settings.permission_mode = + Some(mode.to_string()); + ( + session.cli_session_id.clone(), + session.configuration.settings.clone(), + ) + }); + if let Some((Some(durable), settings)) = adopted { + state + .adopt_session_init( + &durable, + &session_id, + &session_type, + Some(&settings), + identity_sink.clone(), + // Conn-less, like a settings apply: the + // ledger merge keeps the row's stamps. + None, + ) + .await; } } } @@ -7554,6 +7611,11 @@ fn fold_pending_frame(pending: &std::sync::Mutex, value: &Value) .and_then(Value::as_str) .map(str::to_string), input: value.get("tool").and_then(|t| t.get("input")).cloned(), + // Normalized like the questions below: the snapshot's strict + // parse must never see an unknown variant or key (AGENT-06). + suggestions: crate::claude_permission_update::normalize_permission_updates( + value.get("suggestions"), + ), }; let mut p = pending.lock().expect("pending lock"); p.permissions.retain(|e| e.request_id != entry.request_id); @@ -17816,6 +17878,7 @@ rl.on('line', (line) => { blocked_path: None, decision_reason: None, input: None, + suggestions: None, }], questions: Vec::new(), }; @@ -17887,6 +17950,141 @@ rl.on('line', (line) => { drop(env); } + /// Client-alerts G20 (D4): a decision whose `updatedPermissions` is not a list of + /// well-formed permission updates is refused with `INVALID_APPROVAL_DECISION` + /// before anything reaches the sidecar, and the request stays pending; a retry + /// with well-formed updates is written VERBATIM and resolves it. + #[tokio::test] + async fn approval_respond_refuses_malformed_updated_permissions_until_a_valid_retry() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-approval-bad-updates"), None) + .await; + let created = await_claude_created(&mut rx, "req-approval-bad-updates").await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + st.handle_send(send_msg(&session_id, "__raise_permission__")) + .await; + await_pending_permission(&st, &session_id, "req-1").await; + + st.handle_approval_respond(approval_respond_msg( + &session_id, + SessionType::Freshclaude, + "req-1", + json!({ "behavior": "allow", "updatedPermissions": [ + { "type": "grantEverything", "destination": "session" }, + ] }), + )) + .await; + + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; + assert_eq!(frame["event"]["code"], "INVALID_APPROVAL_DECISION"); + assert_eq!(frame["sessionId"], json!(session_id)); + let (permissions, _) = pending_request_ids(&st, &session_id).await; + assert_eq!( + permissions, + vec!["req-1".to_string()], + "a refused decision leaves the request pending" + ); + assert!( + env.respond_log_frames(0).await.is_empty(), + "a refused decision never reaches the sidecar" + ); + + let decision = json!({ "behavior": "allow", "updatedPermissions": [ + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm test *" }], + "behavior": "allow", "destination": "localSettings" }, + { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, + ] }); + st.handle_approval_respond(approval_respond_msg( + &session_id, + SessionType::Freshclaude, + "req-1", + decision.clone(), + )) + .await; + let frames = env.respond_log_frames(1).await; + let respond = frames + .iter() + .find(|f| f["type"] == "permission.respond") + .expect("the valid retry reaches the sidecar"); + assert_eq!(respond["decision"], decision, "forwarded verbatim"); + let (permissions, _) = pending_request_ids(&st, &session_id).await; + assert!( + permissions.is_empty(), + "the valid retry resolves the request" + ); + drop(env); + } + + /// Client-alerts G20 (D4): the refusal of a malformed `updatedPermissions` is + /// logged and reported by the failing update's position and variant only. A + /// rule's content (a Bash command, a path) is user content, which claude logs + /// never carry, and the refused update is never echoed back. + #[tokio::test] + async fn a_refused_decision_names_the_update_by_position_and_variant_never_its_content() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + // Another test reaches the same `warn!` (under this lock), and tracing + // caches a callsite's interest from the first thread that reaches it, so + // that thread (no capture) can leave it cached as "never". Rebuild the + // cache once this thread's capture is installed. + let (events, _capture_guard) = info_capture_for_test::capture(); + tracing::callsite::rebuild_interest_cache(); + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + st.handle_create(dedup_create_msg("req-approval-refusal-log"), None) + .await; + let created = await_claude_created(&mut rx, "req-approval-refusal-log").await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + st.handle_send(send_msg(&session_id, "__raise_permission__")) + .await; + await_pending_permission(&st, &session_id, "req-1").await; + + const SECRET: &str = "deploy --token s3cr3t-value"; + st.handle_approval_respond(approval_respond_msg( + &session_id, + SessionType::Freshclaude, + "req-1", + json!({ "behavior": "allow", "updatedPermissions": [ + { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": SECRET }], + "behavior": "allow", "destination": "session", "extra": 1 }, + ] }), + )) + .await; + + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; + assert_eq!(frame["event"]["code"], "INVALID_APPROVAL_DECISION"); + assert!( + !frame.to_string().contains("s3cr3t"), + "the error frame never echoes the refused update: {frame}" + ); + let events = events.lock().expect("capture lock"); + // The capture records every field through `Debug`, so a string field + // keeps its quotes. + let field = |fields: &std::collections::BTreeMap, key: &str| { + fields + .get(key) + .map(|value| value.trim_matches('"').to_string()) + }; + let (_, fields) = events + .iter() + .find(|(_, fields)| { + field(fields, "event").as_deref() + == Some("freshagent.claude.approval_decision_refused") + }) + .expect("the refusal is logged"); + assert_eq!(field(fields, "update_index").as_deref(), Some("1")); + assert_eq!(field(fields, "update_type").as_deref(), Some("addRules")); + for (key, value) in fields { + assert!( + !value.contains("s3cr3t"), + "the refusal log never carries rule content (`{key}` = {value})" + ); + } + drop(env); + } + /// Task 2 (b): a requestId outside the pending set is refused LOUDLY with the parity /// message on the nested hub-frame — and NOTHING is written to the sidecar (Rust /// validates against its pending set first; the sidecar's unknown-id path is diff --git a/crates/freshell-freshagent/src/claude_permission_update.rs b/crates/freshell-freshagent/src/claude_permission_update.rs new file mode 100644 index 000000000..75913dc5b --- /dev/null +++ b/crates/freshell-freshagent/src/claude_permission_update.rs @@ -0,0 +1,396 @@ +//! Claude permission updates (the SDK's `PermissionUpdate`, client-alerts plan +//! G20): the CLI's own suggestions on a permission request +//! (`sdk.permission.request.suggestions`) and the `updatedPermissions` a +//! client sends back to accept them. +//! +//! [`normalize_permission_updates`] keeps only well-formed updates of the +//! documented variants, with exactly the documented keys. The snapshot's +//! `pendingApprovals[].suggestions` is parsed by a `.strict()` client schema +//! (`FreshAgentPermissionUpdateSchema`), so one unknown variant or extra key +//! relayed raw would fail the whole snapshot and hide every card (the +//! AGENT-06 lesson behind `normalize_question_definitions`). +//! +//! [`validate_decision`] refuses an approval decision whose +//! `updatedPermissions` is not such a list: the sidecar resolves a decision +//! verbatim, and a malformed update reaching the CLI would fail or misapply +//! the user's choice. The [`DecisionRefusal`] names the failing update by its +//! position and variant only: a rule's content (a Bash command, a path) is +//! user content, which claude logs never carry. + +use std::fmt; + +use serde_json::{Map, Value}; + +/// Where an update applies (the SDK's `PermissionUpdateDestination`). +const DESTINATIONS: [&str; 5] = [ + "userSettings", + "projectSettings", + "localSettings", + "session", + "cliArg", +]; +/// The SDK's `PermissionUpdate` variants (its `type`). +const KINDS: [&str; 6] = [ + "addRules", + "replaceRules", + "removeRules", + "setMode", + "addDirectories", + "removeDirectories", +]; +/// The SDK's `PermissionBehavior`. +const BEHAVIORS: [&str; 3] = ["allow", "deny", "ask"]; +/// The SDK's `PermissionMode` (a wider set than the four a pane can show). +const MODES: [&str; 6] = [ + "default", + "acceptEdits", + "bypassPermissions", + "plan", + "dontAsk", + "auto", +]; + +/// Normalize a list of permission updates: every well-formed update of a +/// documented variant, rebuilt with exactly its documented keys, in order. +/// Anything else (a non-list, an unknown variant, a bad destination, mode or +/// behavior, an empty rule or directory list) is dropped. `None` when nothing +/// survives. +pub(crate) fn normalize_permission_updates(value: Option<&Value>) -> Option> { + let updates: Vec = value? + .as_array()? + .iter() + .filter_map(normalize_permission_update) + .collect(); + (!updates.is_empty()).then_some(updates) +} + +/// Why [`validate_decision`] refused a decision, without any of its content. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DecisionRefusal { + /// `updatedPermissions` is present but not a list. + NotAList, + /// The update at `index` is not a well-formed update. `kind` is its + /// variant when Claude defines one, else `"unknown"` (a client-chosen + /// `type` is never echoed). + MalformedUpdate { index: usize, kind: &'static str }, +} + +impl DecisionRefusal { + /// The failing update's position, when one update failed. + pub(crate) fn update_index(self) -> Option { + match self { + Self::NotAList => None, + Self::MalformedUpdate { index, .. } => Some(index), + } + } + + /// The failing update's variant, when one update failed. + pub(crate) fn update_type(self) -> Option<&'static str> { + match self { + Self::NotAList => None, + Self::MalformedUpdate { kind, .. } => Some(kind), + } + } +} + +impl fmt::Display for DecisionRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotAList => { + f.write_str("updatedPermissions must be a list of permission updates") + } + Self::MalformedUpdate { index, kind } => write!( + f, + "updatedPermissions[{index}] ({kind}) is not a permission update Claude accepts" + ), + } + } +} + +/// Check an approval decision's `updatedPermissions`: absent is fine; +/// present, it must be a list every member of which is already a +/// well-formed update (it normalizes to itself). +pub(crate) fn validate_decision(decision: &Value) -> Result<(), DecisionRefusal> { + let Some(updates) = decision.get("updatedPermissions") else { + return Ok(()); + }; + let list = updates.as_array().ok_or(DecisionRefusal::NotAList)?; + match list + .iter() + .position(|update| normalize_permission_update(update).as_ref() != Some(update)) + { + Some(index) => Err(DecisionRefusal::MalformedUpdate { + index, + kind: kind_of(&list[index]), + }), + None => Ok(()), + } +} + +/// An update's variant as Claude names it, or `"unknown"`. +fn kind_of(update: &Value) -> &'static str { + let kind = update.get("type").and_then(Value::as_str); + KINDS + .into_iter() + .find(|known| Some(*known) == kind) + .unwrap_or("unknown") +} + +/// One update, rebuilt with exactly the keys of its variant, or `None`. +fn normalize_permission_update(update: &Value) -> Option { + let object = update.as_object()?; + let kind = object.get("type")?.as_str()?; + let destination = one_of(object.get("destination"), &DESTINATIONS)?; + let mut out = Map::new(); + out.insert("type".to_string(), Value::String(kind.to_string())); + match kind { + "addRules" | "replaceRules" | "removeRules" => { + let rules = object + .get("rules")? + .as_array()? + .iter() + .map(normalize_rule) + .collect::>>()?; + if rules.is_empty() { + return None; + } + out.insert("rules".to_string(), Value::Array(rules)); + out.insert( + "behavior".to_string(), + one_of(object.get("behavior"), &BEHAVIORS)?, + ); + } + "setMode" => { + out.insert("mode".to_string(), one_of(object.get("mode"), &MODES)?); + } + "addDirectories" | "removeDirectories" => { + let directories = object.get("directories")?.as_array()?; + if directories.is_empty() || !directories.iter().all(Value::is_string) { + return None; + } + out.insert("directories".to_string(), Value::Array(directories.clone())); + } + _ => return None, + } + out.insert("destination".to_string(), destination); + Some(Value::Object(out)) +} + +/// One rule (`PermissionRuleValue`): `toolName`, and `ruleContent` when given. +fn normalize_rule(rule: &Value) -> Option { + let object = rule.as_object()?; + let mut out = Map::new(); + let tool_name = object.get("toolName")?.as_str()?; + out.insert("toolName".to_string(), Value::String(tool_name.to_string())); + match object.get("ruleContent") { + None => {} + Some(Value::String(content)) => { + out.insert("ruleContent".to_string(), Value::String(content.clone())); + } + Some(_) => return None, + } + Some(Value::Object(out)) +} + +/// `value` when it is one of `allowed`. +fn one_of(value: Option<&Value>, allowed: &[&str]) -> Option { + let text = value?.as_str()?; + allowed + .contains(&text) + .then(|| Value::String(text.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn normalized(value: Value) -> Option> { + normalize_permission_updates(Some(&value)) + } + + /// One of every documented variant, as the pinned CLI and SDK shape them. + fn every_variant() -> Vec { + vec![ + json!({ "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm test *" }], "behavior": "allow", "destination": "localSettings" }), + json!({ "type": "replaceRules", "rules": [{ "toolName": "Read" }], "behavior": "deny", "destination": "projectSettings" }), + json!({ "type": "removeRules", "rules": [{ "toolName": "Edit", "ruleContent": "src/**" }], "behavior": "ask", "destination": "userSettings" }), + json!({ "type": "setMode", "mode": "acceptEdits", "destination": "session" }), + json!({ "type": "setMode", "mode": "bypassPermissions", "destination": "cliArg" }), + json!({ "type": "addDirectories", "directories": ["/srv/data"], "destination": "session" }), + json!({ "type": "removeDirectories", "directories": ["/srv/old", "/srv/older"], "destination": "localSettings" }), + ] + } + + #[test] + fn every_documented_variant_is_kept_exactly() { + let updates = every_variant(); + assert_eq!(normalized(Value::Array(updates.clone())), Some(updates)); + } + + #[test] + fn every_permission_mode_the_sdk_names_is_kept() { + for mode in [ + "default", + "acceptEdits", + "bypassPermissions", + "plan", + "dontAsk", + "auto", + ] { + let update = json!({ "type": "setMode", "mode": mode, "destination": "session" }); + assert_eq!( + normalized(json!([update.clone()])), + Some(vec![update]), + "{mode}" + ); + } + } + + #[test] + fn an_unknown_variant_a_bad_destination_or_a_bad_mode_is_dropped_and_the_rest_kept() { + let good = json!({ "type": "setMode", "mode": "acceptEdits", "destination": "session" }); + let list = json!([ + { "type": "grantEverything", "destination": "session" }, + { "type": "addRules", "rules": [{ "toolName": "Bash" }], "behavior": "allow", "destination": "everywhere" }, + { "type": "setMode", "mode": "yolo", "destination": "session" }, + good, + ]); + assert_eq!(normalized(list), Some(vec![good])); + } + + #[test] + fn malformed_members_are_dropped() { + assert_eq!( + normalized(json!([ + "addRules", + null, + { "rules": [{ "toolName": "Bash" }], "behavior": "allow", "destination": "session" }, + { "type": "addRules", "behavior": "allow", "destination": "session" }, + { "type": "addRules", "rules": [], "behavior": "allow", "destination": "session" }, + { "type": "addRules", "rules": [{ "ruleContent": "x" }], "behavior": "allow", "destination": "session" }, + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": 7 }], "behavior": "allow", "destination": "session" }, + { "type": "addRules", "rules": [{ "toolName": "Bash" }], "behavior": "always", "destination": "session" }, + { "type": "addRules", "rules": [{ "toolName": "Bash" }], "behavior": "allow" }, + { "type": "addDirectories", "directories": [], "destination": "session" }, + { "type": "addDirectories", "directories": ["/a", 3], "destination": "session" }, + { "type": "setMode", "destination": "session" }, + ])), + None + ); + } + + #[test] + fn extra_keys_are_stripped_at_every_level() { + assert_eq!( + normalized(json!([ + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "ls", "note": "x" }], "behavior": "allow", "destination": "session", "id": 4 }, + { "type": "setMode", "mode": "plan", "destination": "session", "reason": "plan first" }, + { "type": "addDirectories", "directories": ["/a"], "destination": "session", "recursive": true }, + ])), + Some(vec![ + json!({ "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "ls" }], "behavior": "allow", "destination": "session" }), + json!({ "type": "setMode", "mode": "plan", "destination": "session" }), + json!({ "type": "addDirectories", "directories": ["/a"], "destination": "session" }), + ]) + ); + } + + #[test] + fn an_empty_absent_or_non_list_value_is_none() { + assert_eq!(normalized(json!([])), None); + assert_eq!(normalize_permission_updates(None), None); + assert_eq!(normalized(Value::Null), None); + assert_eq!( + normalized(json!({ "type": "setMode", "mode": "plan", "destination": "session" })), + None + ); + } + + #[test] + fn a_decision_without_updated_permissions_or_with_an_empty_list_is_valid() { + assert_eq!(validate_decision(&json!({ "behavior": "allow" })), Ok(())); + assert_eq!( + validate_decision(&json!({ "behavior": "deny", "message": "no", "interrupt": false })), + Ok(()) + ); + assert_eq!( + validate_decision(&json!({ "behavior": "allow", "updatedPermissions": [] })), + Ok(()) + ); + } + + #[test] + fn a_decision_with_well_formed_updates_is_valid() { + assert_eq!( + validate_decision( + &json!({ "behavior": "allow", "updatedPermissions": every_variant() }) + ), + Ok(()) + ); + } + + #[test] + fn a_decision_whose_updated_permissions_is_not_a_well_formed_list_is_refused() { + for bad in [ + json!(null), + json!("addRules"), + json!(3), + json!({ "type": "setMode", "mode": "plan", "destination": "session" }), + json!([{ "type": "grantEverything", "destination": "session" }]), + json!([{ "type": "setMode", "mode": "plan", "destination": "session", "extra": 1 }]), + json!([ + { "type": "setMode", "mode": "plan", "destination": "session" }, + { "type": "setMode", "mode": "yolo", "destination": "session" }, + ]), + ] { + let decision = json!({ "behavior": "allow", "updatedPermissions": bad }); + assert!(validate_decision(&decision).is_err(), "accepted {decision}"); + } + } + + #[test] + fn a_refusal_names_the_update_by_position_and_variant_never_by_its_content() { + let decision = json!({ "behavior": "allow", "updatedPermissions": [ + { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "deploy --token s3cr3t" }], + "behavior": "allow", "destination": "session", "extra": 1 }, + ] }); + let refusal = validate_decision(&decision).unwrap_err(); + assert_eq!( + refusal, + DecisionRefusal::MalformedUpdate { + index: 1, + kind: "addRules" + } + ); + let text = refusal.to_string(); + assert!( + !text.contains("s3cr3t"), + "the refusal echoes content: {text}" + ); + assert!(text.contains("updatedPermissions[1]"), "{text}"); + assert!(text.contains("addRules"), "{text}"); + } + + #[test] + fn a_refusal_never_echoes_a_variant_name_claude_does_not_define() { + let decision = json!({ "behavior": "allow", "updatedPermissions": [ + { "type": "grant everything in /home/me/s3cr3t", "destination": "session" }, + ] }); + let refusal = validate_decision(&decision).unwrap_err(); + assert_eq!( + refusal, + DecisionRefusal::MalformedUpdate { + index: 0, + kind: "unknown" + } + ); + let text = refusal.to_string(); + assert!( + !text.contains("s3cr3t"), + "the refusal echoes content: {text}" + ); + assert!(text.contains("updatedPermissions[0]"), "{text}"); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 460b7d627..8fdc23bfb 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -41,6 +41,7 @@ pub(crate) mod claude_dead_sessions; pub(crate) mod claude_handshake; pub(crate) mod claude_launch; pub(crate) mod claude_live_events; +pub(crate) mod claude_permission_update; pub(crate) mod claude_projection; pub(crate) mod claude_projection_cache; pub(crate) mod claude_sidecar_exit; diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index dea80f6cc..e0d049a24 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -890,6 +890,70 @@ mod tests { assert_eq!(value["capabilities"]["questions"], json!(true)); } + /// Client-alerts G20 (D3): a pending approval carries the CLI's own suggestions + /// (the updates "Allow and don't ask again" sends back), normalized at fold time + /// to the strict `FreshAgentPermissionUpdateSchema` shape: well-formed updates + /// keep exactly their documented keys, anything else is dropped, and an approval + /// with none left has no `suggestions` key at all. + #[tokio::test] + async fn claude_pending_approvals_carry_the_clis_suggestions_normalized() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let durable = "82828282-8282-4828-8828-828282828282"; + let (_home, _content) = stage_transcript(durable); + let claude = claude_state(); + crate::claude::tests::insert_fake_claude_session_with_pending( + &claude, + "client-nanoid-suggest", + Some(durable), + &[ + json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-npm", + "subtype": "can_use_tool", + "tool": { "name": "Bash", "input": { "command": "cat /srv/notes && npm test" } }, + "suggestions": [ + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm test *", "source": "cli" }], + "behavior": "allow", "destination": "localSettings" }, + { "type": "addRules", "rules": [{ "toolName": "Read", "ruleContent": "//srv/**" }], + "behavior": "allow", "destination": "session" }, + { "type": "grantEverything", "destination": "session" }, + ] }), + json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-root", + "subtype": "can_use_tool", + "tool": { "name": "Bash", "input": { "command": "ls /" } }, + "suggestions": [], "blockedPath": "/" }), + ], + ) + .await; + + let state = SnapshotState::new( + Arc::new("tok".to_string()), + codex_state(), + opencode_state(), + claude, + ); + let (status, value) = get_json_with_state(state, "freshclaude", durable).await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + assert_eq!(status, StatusCode::OK); + assert_eq!( + value["pendingApprovals"], + json!([ + { + "requestId": "req-npm", "toolName": "Bash", + "input": { "command": "cat /srv/notes && npm test" }, + "suggestions": [ + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm test *" }], + "behavior": "allow", "destination": "localSettings" }, + { "type": "addRules", "rules": [{ "toolName": "Read", "ruleContent": "//srv/**" }], + "behavior": "allow", "destination": "session" }, + ], + }, + { + "requestId": "req-root", "toolName": "Bash", "blockedPath": "/", + "input": { "command": "ls /" }, + }, + ]) + ); + } + /// Delta-review round 5 (AGENT-06): an SDK-valid question whose options carry the /// documented `preview` field (plus other extras the sidecar preserves verbatim via /// `permission-channel.mjs`'s `...o`) must still produce a snapshot whose diff --git a/crates/freshell-ws/tests/freshagent_claude_approvals.rs b/crates/freshell-ws/tests/freshagent_claude_approvals.rs new file mode 100644 index 000000000..360a97248 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_approvals.rs @@ -0,0 +1,223 @@ +//! Claude approvals with the CLI's own permission suggestions (client-alerts +//! plan G20), end to end over the REAL claude sidecar and the CLI simulator +//! (RealSidecarSim, `common::claude_rig`), a real freshell-ws server, its +//! snapshot REST route and a WS client. +//! +//! - The approval's suggestions reach the snapshot the card renders from, +//! normalized to the strict contract shape. +//! - "Allow and don't ask again" sends them back as `updatedPermissions`, and +//! the CLI receives exactly that decision; a malformed list is refused with +//! `INVALID_APPROVAL_DECISION`, nothing reaches the CLI and the card stays. +//! - A permission mode the CLI moves to by itself (an accepted `setMode`) is +//! the session's mode from then on, across a server restart too. + +mod common; + +use serde_json::{json, Value}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; + +const APPROVAL_PROMPT: &str = "run the tests"; + +/// The CLI's suggestions for `npm test`, plus one no client could parse. +fn offered_suggestions() -> Value { + json!([ + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm test *" }], + "behavior": "allow", "destination": "localSettings" }, + { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, + { "type": "grantEverything", "destination": "session" }, + ]) +} + +/// What the snapshot states: the well-formed suggestions only. +fn normalized_suggestions() -> Value { + json!([ + { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm test *" }], + "behavior": "allow", "destination": "localSettings" }, + { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, + ]) +} + +async fn approvals_rig() -> ClaudeRig { + let program = json!({ "rules": [ + { "on": "msg:prompt", "match": { "text": APPROVAL_PROMPT }, "emit": [ + { "kind": "approval", "data": { + "tool": "Bash", "input": { "command": "npm test" }, + "suggestions": offered_suggestions(), + } }, + { "kind": "text", "data": { "text": "tests ran" } }, + ] }, + ] }) + .to_string(); + ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + env: vec![("FRESHELL_CLAUDE_SIM_PROGRAM".into(), program)], + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await +} + +/// Create a session in `default` mode (where the CLI asks before a Bash call) +/// and raise the approval; returns (pane id, durable id, request id). +async fn raise_approval(conn: &mut RigConnection) -> (String, String, Value) { + let created = conn + .request_create("approvals", json!({ "permissionMode": "default" })) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let pane = created["sessionId"].as_str().unwrap().to_string(); + conn.send_text(&pane, "send-approval", APPROVAL_PROMPT) + .await; + let request = conn.wait_for_event("freshAgent.permission.request").await; + let durable = conn.init_session_id(&pane); + (pane, durable, request["event"]["requestId"].clone()) +} + +fn respond(pane: &str, request_id: &Value, decision: Value) -> Value { + json!({ + "type": "freshAgent.approval.respond", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane, + "requestId": request_id, + "decision": decision, + }) +} + +/// Poll the snapshot route until `ready` holds. +async fn snapshot_when( + rig: &ClaudeRig, + thread_id: &str, + what: &str, + ready: impl Fn(&Value) -> bool, +) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let (status, snapshot) = rig.snapshot(thread_id).await; + if status == 200 && ready(&snapshot) { + return snapshot; + } + assert!( + tokio::time::Instant::now() < deadline, + "the snapshot never showed {what}; last answer {status}: {snapshot}" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +/// What the simulated CLI's canUseTool resolved with, in order. +fn cli_decisions(rig: &ClaudeRig) -> Vec { + rig.ledger_rows("can_use_tool") + .into_iter() + .map(|row| row["decision"].clone()) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_remembered_approval_sends_the_clis_suggestions_and_a_malformed_list_is_refused() { + let rig = approvals_rig().await; + let mut conn = rig.connect().await; + let (pane, durable, request_id) = raise_approval(&mut conn).await; + + let pending = snapshot_when(&rig, &durable, "the pending approval", |snapshot| { + snapshot["pendingApprovals"] + .as_array() + .is_some_and(|list| !list.is_empty()) + }) + .await; + assert_eq!( + pending["pendingApprovals"][0]["suggestions"], + normalized_suggestions() + ); + + // A malformed list: refused, nothing reaches the CLI, the card stays. + conn.send(respond( + &pane, + &request_id, + json!({ "behavior": "allow", "updatedPermissions": offered_suggestions() }), + )) + .await; + let refusal = conn.wait_for_event("freshAgent.error").await; + assert_eq!(refusal["event"]["code"], "INVALID_APPROVAL_DECISION"); + let (_, still) = rig.snapshot(&durable).await; + assert_eq!( + still["pendingApprovals"][0]["requestId"], request_id, + "{still}" + ); + assert!(cli_decisions(&rig).is_empty()); + + // The suggestions as the card has them: the CLI gets exactly this decision. + let decision = json!({ + "behavior": "allow", + "updatedPermissions": pending["pendingApprovals"][0]["suggestions"], + }); + conn.send(respond(&pane, &request_id, decision.clone())) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while cli_decisions(&rig).is_empty() { + assert!( + tokio::time::Instant::now() < deadline, + "the CLI never got the decision" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!(cli_decisions(&rig), vec![decision]); + // The accepted setMode moves the CLI to acceptEdits; every client hears it. + conn.wait_for("the CLI's own mode change", |frame| { + frame["sessionId"] == pane.as_str() + && frame["event"]["type"] == "freshAgent.session.metadata" + && frame["event"]["permissionMode"] == "acceptEdits" + }) + .await; + snapshot_when(&rig, &durable, "the approval answered", |snapshot| { + snapshot["pendingApprovals"] == json!([]) + && snapshot["settings"]["permissionMode"] == "acceptEdits" + }) + .await; + conn.kill(&pane).await; +} + +/// The mode the CLI moved to by itself is saved with the session: after a +/// server restart the pane's attach resumes the conversation in that mode, +/// not in the mode the pane created it with. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_mode_the_cli_moved_to_is_the_mode_a_resume_boots_in() { + let mut rig = approvals_rig().await; + let mut conn = rig.connect().await; + let (pane, durable, request_id) = raise_approval(&mut conn).await; + conn.send(respond( + &pane, + &request_id, + json!({ "behavior": "allow", "updatedPermissions": normalized_suggestions() }), + )) + .await; + conn.wait_for("the CLI's own mode change", |frame| { + frame["sessionId"] == pane.as_str() + && frame["event"]["type"] == "freshAgent.session.metadata" + && frame["event"]["permissionMode"] == "acceptEdits" + }) + .await; + conn.wait_for_event("freshAgent.result").await; + rig.wait_for_transcript(&durable).await; + drop(conn); + + rig.restart().await; + let mut conn = rig.connect().await; + conn.attach(&durable).await; + let boots: Vec = rig + .ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect(); + assert_eq!( + boots.len(), + 2, + "the create's boot and the resume's: {boots:?}" + ); + assert_eq!(boots[0]["permissionMode"], "default", "{:?}", boots[0]); + assert_eq!(boots[1]["resume"], durable.as_str(), "{:?}", boots[1]); + assert_eq!(boots[1]["permissionMode"], "acceptEdits", "{:?}", boots[1]); + conn.kill(&durable).await; +} diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts index 83e27e2ed..15166df97 100644 --- a/shared/fresh-agent-contract.ts +++ b/shared/fresh-agent-contract.ts @@ -261,6 +261,40 @@ export const FreshAgentTurnSchema = z.object({ restorable: z.boolean().optional(), }).strict() +// Claude's permission update (the SDK's `PermissionUpdate`): what the CLI +// suggests on an approval, and what "Allow and don't ask again" sends back as +// `updatedPermissions`. Mirrors the server's normalizer +// (crates/freshell-freshagent/src/claude_permission_update.rs), which drops +// anything outside this shape before it reaches a snapshot. +const FreshAgentPermissionUpdateDestinationSchema = z.enum(['userSettings', 'projectSettings', 'localSettings', 'session', 'cliArg']) +const FreshAgentPermissionRulesSchema = z.array(z.object({ + toolName: z.string(), + ruleContent: z.string().optional(), +}).strict()).min(1) +const FreshAgentPermissionRulesUpdateSchema = (type: 'addRules' | 'replaceRules' | 'removeRules') => z.object({ + type: z.literal(type), + rules: FreshAgentPermissionRulesSchema, + behavior: z.enum(['allow', 'deny', 'ask']), + destination: FreshAgentPermissionUpdateDestinationSchema, +}).strict() +const FreshAgentPermissionDirectoriesUpdateSchema = (type: 'addDirectories' | 'removeDirectories') => z.object({ + type: z.literal(type), + directories: z.array(z.string()).min(1), + destination: FreshAgentPermissionUpdateDestinationSchema, +}).strict() +export const FreshAgentPermissionUpdateSchema = z.discriminatedUnion('type', [ + FreshAgentPermissionRulesUpdateSchema('addRules'), + FreshAgentPermissionRulesUpdateSchema('replaceRules'), + FreshAgentPermissionRulesUpdateSchema('removeRules'), + z.object({ + type: z.literal('setMode'), + mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk', 'auto']), + destination: FreshAgentPermissionUpdateDestinationSchema, + }).strict(), + FreshAgentPermissionDirectoriesUpdateSchema('addDirectories'), + FreshAgentPermissionDirectoriesUpdateSchema('removeDirectories'), +]) + export const FreshAgentPendingApprovalSchema = z.object({ requestId: FreshAgentRequestIdSchema, toolName: z.string().optional(), @@ -268,6 +302,8 @@ export const FreshAgentPendingApprovalSchema = z.object({ blockedPath: z.string().optional(), decisionReason: z.string().optional(), input: z.record(z.string(), z.unknown()).optional(), + // Claude only: the CLI's own suggestions for not asking again. + suggestions: z.array(FreshAgentPermissionUpdateSchema).optional(), providerRequest: z.record(z.string(), z.unknown()).optional(), }).strict() @@ -427,6 +463,7 @@ export type FreshAgentRequestId = z.infer export type FreshAgentTranscriptItem = z.infer export type FreshAgentTurn = z.infer export type FreshAgentPendingApproval = z.infer +export type FreshAgentPermissionUpdate = z.infer export type FreshAgentPendingQuestion = z.infer export type FreshAgentSessionCommand = z.infer export type FreshAgentSnapshot = z.infer diff --git a/src/components/fresh-agent/FreshAgentApprovalCard.tsx b/src/components/fresh-agent/FreshAgentApprovalCard.tsx index 3b8dc790b..f8f44b6f4 100644 --- a/src/components/fresh-agent/FreshAgentApprovalCard.tsx +++ b/src/components/fresh-agent/FreshAgentApprovalCard.tsx @@ -1,32 +1,61 @@ +import { useId } from 'react' import DiffView from '@/components/fresh-agent/shared/DiffView' import type { FreshAgentPendingApproval } from '@shared/fresh-agent-contract' +import { + buildAllowDecision, + buildDenyDecision, + describePermissionUpdate, + rememberLabel, + type FreshAgentApprovalDecision, +} from '@/lib/fresh-agent-permission-updates' +import { FreshAgentPlanApprovalCard } from './FreshAgentPlanApprovalCard' + +// Touch targets ≥44px at base; compact on sm+. flex-1 lets the buttons share +// the full row width on narrow panes for easy thumbing. +const BUTTON = 'fresh-agent-approval-button min-h-[2.75rem] flex-1 rounded-md px-4 text-sm disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0 sm:flex-none sm:px-3 sm:py-1 sm:text-xs' + +type FreshAgentApprovalCardProps = { + approval: FreshAgentPendingApproval + disabled?: boolean + onRespond: (decision: FreshAgentApprovalDecision) => void + onAlwaysAllow?: (toolName: string) => void +} /** * Approval card with an inline preview of what is being approved — the exact - * Bash command, an Edit diff, or pretty-printed input for other tools — plus a - * session-scoped "always allow" escape hatch handled by the caller. + * Bash command, an Edit diff, or pretty-printed input for other tools. Every + * answer goes out through `onRespond` as a decision from the shared builders: + * + * - "Allow" and "Deny"; + * - when the CLI suggested permission updates (Claude only), the remember + * button sends exactly those suggestions, each spelled out in plain language + * underneath, so Claude itself stops asking; + * - `onAlwaysAllow`, when the caller provides it, adds the client-only "Always + * allow in this pane" (codex): the caller remembers the tool and + * answers later requests until the pane reloads. + * + * Claude's ExitPlanMode request is a plan to review, not a tool call: it gets + * the plan card ({@link FreshAgentPlanApprovalCard}). */ -export function FreshAgentApprovalCard({ - approval, - disabled, - onAllow, - onAlwaysAllow, - onDeny, -}: { - approval: FreshAgentPendingApproval - disabled?: boolean - onAllow: () => void - onAlwaysAllow: (toolName: string) => void - onDeny: () => void -}) { +export function FreshAgentApprovalCard(props: FreshAgentApprovalCardProps) { + if (props.approval.toolName === 'ExitPlanMode') { + return + } + return +} + +function FreshAgentToolApprovalCard({ approval, disabled, onRespond, onAlwaysAllow }: FreshAgentApprovalCardProps) { + const suggestionsId = useId() const toolName = approval.toolName ?? 'Tool' const input = approval.input ?? {} + const suggestions = approval.suggestions ?? [] const command = toolName === 'Bash' && typeof input.command === 'string' ? input.command : null const editDiff = toolName === 'Edit' && typeof input.old_string === 'string' && typeof input.new_string === 'string' ? { oldStr: input.old_string, newStr: input.new_string, filePath: typeof input.file_path === 'string' ? input.file_path : undefined } : null + const remember = suggestions.length > 0 ? rememberLabel(suggestions) : null return (
) : null} - {/* Touch targets ≥44px at base; compact on sm+. flex-1 lets the buttons - share the full row width on narrow panes for easy thumbing. */}
- {approval.toolName ? ( + {remember ? ( + + ) : null} + {onAlwaysAllow && approval.toolName ? ( ) : null}
+ {remember ? ( +
    + {suggestions.map((update, index) => ( +
  • {describePermissionUpdate(update)}
  • + ))} +
+ ) : null}
) } diff --git a/src/components/fresh-agent/FreshAgentPlanApprovalCard.tsx b/src/components/fresh-agent/FreshAgentPlanApprovalCard.tsx new file mode 100644 index 000000000..0ed0be002 --- /dev/null +++ b/src/components/fresh-agent/FreshAgentPlanApprovalCard.tsx @@ -0,0 +1,122 @@ +import { useId, useState } from 'react' +import type { FreshAgentPendingApproval } from '@shared/fresh-agent-contract' +import { + buildPlanDecision, + type FreshAgentApprovalDecision, + type PlanApprovalChoice, +} from '@/lib/fresh-agent-permission-updates' +import { FreshAgentMarkdownBody } from './FreshAgentItemCard' + +// Touch targets ≥44px at base; compact on sm+ (the approval card's sizing). +const BUTTON = 'fresh-agent-plan-button min-h-[2.75rem] flex-1 rounded-md px-4 text-sm disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0 sm:flex-none sm:px-3 sm:py-1 sm:text-xs' + +const APPROVALS: Array<{ choice: Exclude; label: string; primary?: boolean }> = [ + { choice: 'approve', label: 'Approve plan', primary: true }, + { choice: 'approve-accept-edits', label: 'Approve and auto-accept edits' }, + { choice: 'approve-bypass', label: 'Approve and stop asking for permission' }, +] + +/** + * Claude's ExitPlanMode request, reviewed as a plan (client-alerts G20): the + * plan the CLI sent (`input.plan`, the plan file's text when the model wrote + * one) rendered as markdown, the file it lives in, and four answers. A plain + * approval lets the CLI return to the mode it had before plan mode; the other + * two set the mode for the rest of the conversation; "Keep planning" denies + * with the user's optional note, and a typed note holds the approvals back so + * it is never dropped. + */ +export function FreshAgentPlanApprovalCard({ + approval, + disabled, + onRespond, +}: { + approval: FreshAgentPendingApproval + disabled?: boolean + onRespond: (decision: FreshAgentApprovalDecision) => void +}) { + const feedbackId = useId() + const noteHintId = useId() + const [feedback, setFeedback] = useState('') + // A note is answered only by "Keep planning" (an approval carries no + // message), so while one is typed the approvals wait: clicking one would + // drop the note and run the plan unchanged. + const hasNote = feedback.trim().length > 0 + const input = approval.input ?? {} + const plan = typeof input.plan === 'string' && input.plan.trim() ? input.plan : null + const planFilePath = typeof input.planFilePath === 'string' ? input.planFilePath : null + const answer = (choice: PlanApprovalChoice) => onRespond(buildPlanDecision(choice, feedback)) + + return ( +
+
+ + plan + + Plan ready for review +
+ {planFilePath ? ( +

+ {planFilePath} +

+ ) : null} +
+ {plan ? ( + + ) : ( +

No plan text was provided. You can still approve it or keep planning.

+ )} +
+ +
+ {APPROVALS.map(({ choice, label, primary }) => ( + + ))} +
+
+ +
+