Skip to content

Commit c58317a

Browse files
feat: dont require consent for safe readonly bash commands (#728)
1 parent 08a1450 commit c58317a

File tree

7 files changed

+79
-5
lines changed

7 files changed

+79
-5
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ semver = { version = "1.0.16", features = ["serde"] }
127127
serde = { version = "1.0.216", features = ["derive", "rc"] }
128128
serde_json = "1.0.115"
129129
sha2 = "0.10.6"
130+
shlex = "1.3.0"
130131
strum = { version = "0.26.3", features = ["derive"] }
131132
sysinfo = "0.32.0"
132133
thiserror = "2.0.3"

crates/figterm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ serde_json.workspace = true
5757
shell-color.workspace = true
5858
shell-words = "1.1"
5959
shellexpand = "3.1.0"
60-
shlex = "1.3.0"
60+
shlex.workspace = true
6161
sysinfo.workspace = true
6262
time.workspace = true
6363
tokio.workspace = true

crates/q_cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ rustyline = { version = "14.0.0", features = ["derive"] }
6767
semver.workspace = true
6868
serde.workspace = true
6969
serde_json.workspace = true
70+
shlex.workspace = true
7071
spinners = "4.1.0"
7172
sysinfo.workspace = true
7273
tempfile.workspace = true

crates/q_cli/src/cli/chat/conversation_state.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ impl ConversationState {
9292
warn!(?next_message, "next_message should not exist");
9393
}
9494

95+
let input = if input.is_empty() {
96+
warn!("input must not be empty when adding new messages");
97+
"Empty prompt".to_string()
98+
} else {
99+
input
100+
};
101+
95102
let msg = UserInputMessage {
96103
content: input,
97104
user_input_message_context: Some(UserInputMessageContext {
@@ -142,6 +149,9 @@ impl ConversationState {
142149
/// are dropped.
143150
/// 3. The last message is from the assistant. The last message is dropped if it is from the
144151
/// user.
152+
/// 4. If the last message is from the assistant and it contains tool uses, and a next user
153+
/// message is set without tool results, then the user message will have cancelled tool
154+
/// results.
145155
pub fn fix_history(&mut self) {
146156
// Trim the conversation history by finding the second oldest message from the user without
147157
// tool results - this will be the new oldest message in the history.
@@ -158,7 +168,7 @@ impl ConversationState {
158168
matches!(
159169
m.user_input_message_context.as_ref(),
160170
Some(ctx) if ctx.tool_results.as_ref().is_none_or(|v| v.is_empty())
161-
)
171+
) && !m.content.is_empty()
162172
},
163173
ChatMessage::AssistantResponseMessage(_) => false,
164174
}
@@ -172,7 +182,6 @@ impl ConversationState {
172182
None => {
173183
debug!("no valid starting user message found in the history, clearing");
174184
self.history.clear();
175-
176185
// Edge case: if the next message contains tool results, then we have to just
177186
// abandon them.
178187
match &mut self.next_message {

crates/q_cli/src/cli/chat/tools/execute_bash.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,34 @@ use super::{
2424
};
2525
use crate::cli::chat::truncate_safe;
2626

27+
const READONLY_COMMANDS: &[&str] = &["ls", "cat", "echo", "pwd", "which", "head", "tail"];
28+
2729
#[derive(Debug, Clone, Deserialize)]
2830
pub struct ExecuteBash {
2931
pub command: String,
3032
}
3133

3234
impl ExecuteBash {
35+
pub fn requires_consent(&self) -> bool {
36+
let Some(args) = shlex::split(&self.command) else {
37+
return true;
38+
};
39+
40+
const DANGEROUS_PATTERNS: &[&str] = &["|", "<(", "$(", "`", ">", "&&", "||"];
41+
if args
42+
.iter()
43+
.any(|arg| DANGEROUS_PATTERNS.iter().any(|p| arg.contains(p)))
44+
{
45+
return true;
46+
}
47+
48+
if let Some(cmd) = args.first() {
49+
!READONLY_COMMANDS.contains(&cmd.as_str())
50+
} else {
51+
true
52+
}
53+
}
54+
3355
pub async fn invoke(&self, mut updates: impl Write) -> Result<InvokeOutput> {
3456
// We need to maintain a handle on stderr and stdout, but pipe it to the terminal as well
3557
let mut child = tokio::process::Command::new("bash")
@@ -171,7 +193,6 @@ mod tests {
171193
// Verifying stderr
172194
let v = serde_json::json!({
173195
"command": "echo Hello, world! 1>&2",
174-
"interactive": false
175196
});
176197
let out = serde_json::from_value::<ExecuteBash>(v)
177198
.unwrap()
@@ -205,4 +226,45 @@ mod tests {
205226
panic!("Expected JSON output");
206227
}
207228
}
229+
230+
#[test]
231+
fn test_requires_consent_for_readonly_commands() {
232+
let cmds = &[
233+
// Safe commands
234+
("ls ~", false),
235+
("ls -al ~", false),
236+
("pwd", false),
237+
("echo 'Hello, world!'", false),
238+
("which aws", false),
239+
// Potentially dangerous readonly commands
240+
("echo hi > myimportantfile", true),
241+
("ls -al >myimportantfile", true),
242+
("echo hi 2> myimportantfile", true),
243+
("echo hi >> myimportantfile", true),
244+
("echo $(rm myimportantfile)", true),
245+
("echo `rm myimportantfile`", true),
246+
("echo hello && rm myimportantfile", true),
247+
("echo hello&&rm myimportantfile", true),
248+
("ls nonexistantpath || rm myimportantfile", true),
249+
("echo myimportantfile | xargs rm", true),
250+
("echo myimportantfile|args rm", true),
251+
("echo <(rm myimportantfile)", true),
252+
("cat <<< 'some string here' > myimportantfile", true),
253+
("echo '\n#!/usr/bin/env bash\necho hello\n' > myscript.sh", true),
254+
("cat <<EOF > myimportantfile\nhello world\nEOF", true),
255+
];
256+
for (cmd, expected) in cmds {
257+
let tool = serde_json::from_value::<ExecuteBash>(serde_json::json!({
258+
"command": cmd,
259+
}))
260+
.unwrap();
261+
assert_eq!(
262+
tool.requires_consent(),
263+
*expected,
264+
"expected command: `{}` to have requires_consent: `{}`",
265+
cmd,
266+
expected
267+
);
268+
}
269+
}
208270
}

crates/q_cli/src/cli/chat/tools/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ impl Tool {
8585
match self {
8686
Tool::FsRead(_) => false,
8787
Tool::FsWrite(_) => true,
88-
Tool::ExecuteBash(_) => true,
88+
Tool::ExecuteBash(execute_bash) => execute_bash.requires_consent(),
8989
Tool::UseAws(use_aws) => use_aws.requires_consent(),
9090
}
9191
}

0 commit comments

Comments
 (0)