Skip to main content

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;
54#[cfg(feature = "async")]
55use crate::command::ClaudeCommand;
56#[cfg(any(feature = "async", feature = "sync"))]
57use crate::command::query::QueryCommand;
58use crate::error::{Error, Result};
59#[cfg(any(feature = "async", feature = "sync"))]
60use crate::exec::CommandOutput;
61#[cfg(any(feature = "async", feature = "sync"))]
62#[allow(deprecated)]
63use crate::types::PermissionMode;
64
65/// The env-var that must equal `"1"` at process start for
66/// [`DangerousClient::new`] to succeed. Set deliberately -- this is
67/// the explicit acknowledgement that bypass mode is OK in this process.
68pub const ALLOW_ENV: &str = "CLAUDE_WRAPPER_ALLOW_DANGEROUS";
69
70/// Wrapper that lets callers run bypass-permissions queries against an
71/// underlying [`Claude`] client. Construction is gated by the
72/// [`ALLOW_ENV`] env-var.
73#[derive(Debug, Clone)]
74pub struct DangerousClient {
75    inner: Claude,
76}
77
78impl DangerousClient {
79    /// Wrap `claude`, refusing to construct unless the [`ALLOW_ENV`]
80    /// env-var equals `"1"`.
81    ///
82    /// The check is made at each construction rather than memoized so
83    /// that a test which flips the env-var mid-process sees the change.
84    pub fn new(claude: Claude) -> Result<Self> {
85        if !is_allowed() {
86            return Err(Error::DangerousNotAllowed { env_var: ALLOW_ENV });
87        }
88        Ok(Self { inner: claude })
89    }
90
91    /// Borrow the underlying [`Claude`] for composition with other
92    /// wrapper APIs.
93    pub fn claude(&self) -> &Claude {
94        &self.inner
95    }
96
97    /// Run `cmd` with `--permission-mode bypassPermissions`
98    /// unconditionally overridden. Any permission mode the caller
99    /// already set on `cmd` is replaced. Requires the `async` feature.
100    #[cfg(feature = "async")]
101    pub async fn query_bypass(&self, cmd: QueryCommand) -> Result<CommandOutput> {
102        #[allow(deprecated)]
103        let cmd = cmd.permission_mode(PermissionMode::BypassPermissions);
104        cmd.execute(&self.inner).await
105    }
106
107    /// Blocking mirror of [`DangerousClient::query_bypass`]. Requires
108    /// the `sync` feature.
109    #[cfg(feature = "sync")]
110    pub fn query_bypass_sync(&self, cmd: QueryCommand) -> Result<CommandOutput> {
111        #[allow(deprecated)]
112        let cmd = cmd.permission_mode(PermissionMode::BypassPermissions);
113        cmd.execute_sync(&self.inner)
114    }
115}
116
117fn is_allowed() -> bool {
118    std::env::var(ALLOW_ENV).as_deref() == Ok("1")
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::sync::Mutex;
125
126    // The env-var is process-global; serialize the tests that touch
127    // it so they don't interleave and flip the answer out from under
128    // each other.
129    static ENV_LOCK: Mutex<()> = Mutex::new(());
130
131    fn with_allow_env<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
132        let _g = ENV_LOCK.lock().unwrap();
133        let prev = std::env::var(ALLOW_ENV).ok();
134        // SAFETY: set_var and remove_var are marked unsafe in recent
135        // std because multi-threaded processes can race. We serialize
136        // with ENV_LOCK above; cargo test runs tests concurrently,
137        // but the lock pins this env-var one caller at a time.
138        unsafe {
139            match value {
140                Some(v) => std::env::set_var(ALLOW_ENV, v),
141                None => std::env::remove_var(ALLOW_ENV),
142            }
143        }
144        let out = f();
145        unsafe {
146            match prev {
147                Some(v) => std::env::set_var(ALLOW_ENV, v),
148                None => std::env::remove_var(ALLOW_ENV),
149            }
150        }
151        out
152    }
153
154    #[test]
155    fn new_refuses_without_env() {
156        with_allow_env(None, || {
157            let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
158            let err = DangerousClient::new(claude).unwrap_err();
159            assert!(matches!(
160                err,
161                Error::DangerousNotAllowed { env_var } if env_var == ALLOW_ENV
162            ));
163        });
164    }
165
166    #[test]
167    fn new_refuses_with_wrong_value() {
168        with_allow_env(Some("true"), || {
169            let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
170            assert!(matches!(
171                DangerousClient::new(claude).unwrap_err(),
172                Error::DangerousNotAllowed { .. }
173            ));
174        });
175        with_allow_env(Some("yes"), || {
176            let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
177            assert!(matches!(
178                DangerousClient::new(claude).unwrap_err(),
179                Error::DangerousNotAllowed { .. }
180            ));
181        });
182    }
183
184    #[test]
185    fn new_accepts_with_allow_env_set_to_one() {
186        with_allow_env(Some("1"), || {
187            let claude = Claude::builder().binary("/usr/bin/true").build().unwrap();
188            let d = DangerousClient::new(claude).unwrap();
189            // The wrapper exposes the underlying client for
190            // composition. Verify the binary threaded through.
191            assert_eq!(d.claude().binary(), std::path::Path::new("/usr/bin/true"));
192        });
193    }
194
195    #[test]
196    fn error_message_names_the_env_var() {
197        // User-facing error has to be clear enough that a reader
198        // knows which env-var to set without digging.
199        let e = Error::DangerousNotAllowed { env_var: ALLOW_ENV };
200        let s = e.to_string();
201        assert!(s.contains(ALLOW_ENV));
202    }
203}