claude_wrapper/dangerous.rs
1//! Opt-in dangerous operations. Currently: bypass permissions.
2//!
3//! Running the `claude` CLI with `--permission-mode bypassPermissions`
4//! turns off all confirmation prompts for tool use. It's legitimate
5//! for some automation -- but it's also the fastest way to turn a bug
6//! into a destructive action.
7//!
8//! This module isolates that capability behind a type you have to
9//! explicitly reach for ([`DangerousClient`]), and a runtime env-var
10//! gate ([`ALLOW_ENV`]) you have to explicitly set.
11//!
12//! # Example
13//!
14//! ```no_run
15//! # async fn example() -> claude_wrapper::Result<()> {
16//! use claude_wrapper::{Claude, QueryCommand};
17//! use claude_wrapper::dangerous::DangerousClient;
18//!
19//! // At process start:
20//! // export CLAUDE_WRAPPER_ALLOW_DANGEROUS=1
21//!
22//! let claude = Claude::builder().build()?;
23//! let dangerous = DangerousClient::new(claude)?;
24//!
25//! let output = dangerous
26//! .query_bypass(QueryCommand::new("clean up the build artifacts"))
27//! .await?;
28//! println!("{}", output.stdout);
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! # Why this shape
34//!
35//! - **Separate type.** `DangerousClient::new` is the only public path
36//! to building a bypassed query. If a reader of calling code sees
37//! `DangerousClient`, the danger is obvious at the call site.
38//! - **Runtime env-var gate.** The check happens at construction, so
39//! a caller who forgot to set the env-var gets a typed error rather
40//! than silently running with bypass off (which might surprise them)
41//! or silently running with bypass on (which might destroy things).
42//! - **Not a cargo feature.** Feature-gating adds a second layer of
43//! friction (recompile) without making the runtime behaviour any
44//! safer. The env-var matches how Go's `claude-code-go/dangerous`
45//! gates the same operation.
46//!
47//! # Migrating from [`crate::PermissionMode::BypassPermissions`]
48//!
49//! The enum variant is kept (marked `#[deprecated]`) so existing
50//! callers continue to compile with a warning. New code should go
51//! through `DangerousClient`.
52
53use crate::Claude;
54use crate::command::{ClaudeCommand, query::QueryCommand};
55use crate::error::{Error, Result};
56use crate::exec::CommandOutput;
57#[allow(deprecated)]
58use crate::types::PermissionMode;
59
60/// The env-var that must equal `"1"` at process start for
61/// [`DangerousClient::new`] to succeed. Set deliberately -- this is
62/// the explicit acknowledgement that bypass mode is OK in this process.
63pub const ALLOW_ENV: &str = "CLAUDE_WRAPPER_ALLOW_DANGEROUS";
64
65/// Wrapper that lets callers run bypass-permissions queries against an
66/// underlying [`Claude`] client. Construction is gated by the
67/// [`ALLOW_ENV`] env-var.
68#[derive(Debug, Clone)]
69pub struct DangerousClient {
70 inner: Claude,
71}
72
73impl DangerousClient {
74 /// Wrap `claude`, refusing to construct unless the [`ALLOW_ENV`]
75 /// env-var equals `"1"`.
76 ///
77 /// The check is made at each construction rather than memoized so
78 /// that a test which flips the env-var mid-process sees the change.
79 pub fn new(claude: Claude) -> Result<Self> {
80 if !is_allowed() {
81 return Err(Error::DangerousNotAllowed { env_var: ALLOW_ENV });
82 }
83 Ok(Self { inner: claude })
84 }
85
86 /// Borrow the underlying [`Claude`] for composition with other
87 /// wrapper APIs.
88 pub fn claude(&self) -> &Claude {
89 &self.inner
90 }
91
92 /// Run `cmd` with `--permission-mode bypassPermissions`
93 /// unconditionally overridden. Any permission mode the caller
94 /// already set on `cmd` is replaced.
95 pub async fn query_bypass(&self, cmd: QueryCommand) -> Result<CommandOutput> {
96 #[allow(deprecated)]
97 let cmd = cmd.permission_mode(PermissionMode::BypassPermissions);
98 cmd.execute(&self.inner).await
99 }
100}
101
102fn is_allowed() -> bool {
103 std::env::var(ALLOW_ENV).as_deref() == Ok("1")
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::sync::Mutex;
110
111 // The env-var is process-global; serialize the tests that touch
112 // it so they don't interleave and flip the answer out from under
113 // each other.
114 static ENV_LOCK: Mutex<()> = Mutex::new(());
115
116 fn with_allow_env<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
117 let _g = ENV_LOCK.lock().unwrap();
118 let prev = std::env::var(ALLOW_ENV).ok();
119 // SAFETY: set_var and remove_var are marked unsafe in recent
120 // std because multi-threaded processes can race. We serialize
121 // with ENV_LOCK above; cargo test runs tests concurrently,
122 // but the lock pins this env-var one caller at a time.
123 unsafe {
124 match value {
125 Some(v) => std::env::set_var(ALLOW_ENV, v),
126 None => std::env::remove_var(ALLOW_ENV),
127 }
128 }
129 let out = f();
130 unsafe {
131 match prev {
132 Some(v) => std::env::set_var(ALLOW_ENV, v),
133 None => std::env::remove_var(ALLOW_ENV),
134 }
135 }
136 out
137 }
138
139 #[test]
140 fn new_refuses_without_env() {
141 with_allow_env(None, || {
142 let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
143 let err = DangerousClient::new(claude).unwrap_err();
144 assert!(matches!(
145 err,
146 Error::DangerousNotAllowed { env_var } if env_var == ALLOW_ENV
147 ));
148 });
149 }
150
151 #[test]
152 fn new_refuses_with_wrong_value() {
153 with_allow_env(Some("true"), || {
154 let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
155 assert!(matches!(
156 DangerousClient::new(claude).unwrap_err(),
157 Error::DangerousNotAllowed { .. }
158 ));
159 });
160 with_allow_env(Some("yes"), || {
161 let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
162 assert!(matches!(
163 DangerousClient::new(claude).unwrap_err(),
164 Error::DangerousNotAllowed { .. }
165 ));
166 });
167 }
168
169 #[test]
170 fn new_accepts_with_allow_env_set_to_one() {
171 with_allow_env(Some("1"), || {
172 let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
173 let d = DangerousClient::new(claude).unwrap();
174 // The wrapper exposes the underlying client for
175 // composition. Verify the binary threaded through.
176 assert_eq!(d.claude().binary(), std::path::Path::new("/usr/bin/true"));
177 });
178 }
179
180 #[test]
181 fn error_message_names_the_env_var() {
182 // User-facing error has to be clear enough that a reader
183 // knows which env-var to set without digging.
184 let e = Error::DangerousNotAllowed { env_var: ALLOW_ENV };
185 let s = e.to_string();
186 assert!(s.contains(ALLOW_ENV));
187 }
188}