Skip to content

Commit 929dc35

Browse files
feat(sql): add session/slow-query/privilege DBA capabilities
Adds 5 new sqlkit capabilities — list_sessions, kill_session, get_slow_queries, grant_privilege, revoke_privilege — via a new capabilities/dba.rs module wired into the registry: - DatabaseAdapter trait gains 5 methods with unsupported default stubs - Implemented for PostgreSQL (pg_stat_activity / pg_terminate_backend / GRANT/REVOKE), MySQL (processlist / KILL / GRANT/REVOKE) and SQL Server (sys.dm_exec_sessions / KILL / sys.dm_exec_query_stats / GRANT/REVOKE) - Identifier whitelist + privilege allowlist guard against SQL injection in GRANT/REVOKE object/grantee arguments - agent-tag regression test asserts all 25 sqlkit capabilities are agent-visible Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 2f56ceb commit 929dc35

9 files changed

Lines changed: 1499 additions & 0 deletions

File tree

src-tauri/src/capabilities/dba.rs

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
//! DBA SQL tools for the MCP bridge.
2+
//!
3+
//! Dedicated capabilities for session inspection/termination, slow-query
4+
//! discovery, and privilege management. These call dedicated
5+
//! `DatabaseAdapter` methods that bypass `classify_sql`, so
6+
//! `Statement::Grant/Revoke/Kill` never reaches the write/DDL gate.
7+
8+
use std::sync::Arc;
9+
10+
use async_trait::async_trait;
11+
use serde_json::{json, Value};
12+
13+
use data_studio_agent::capabilities::registry::CapabilityRegistry;
14+
use data_studio_agent::capabilities::types::{
15+
Capability, CapabilityHandler, RiskLevel, SourceKind,
16+
};
17+
18+
use super::sql::{
19+
get_connection_id, get_slow_queries_on_adapter, grant_privilege_on_adapter,
20+
kill_session_on_adapter, list_sessions_on_adapter, resolve_adapter,
21+
revoke_privilege_on_adapter,
22+
};
23+
24+
// ---------------------------------------------------------------------------
25+
// Handler structs
26+
// ---------------------------------------------------------------------------
27+
28+
struct ListSessionsHandler;
29+
struct KillSessionHandler;
30+
struct GetSlowQueriesHandler;
31+
struct GrantPrivilegeHandler;
32+
struct RevokePrivilegeHandler;
33+
34+
// ---------------------------------------------------------------------------
35+
// Handler implementations
36+
// ---------------------------------------------------------------------------
37+
38+
#[async_trait]
39+
impl CapabilityHandler for ListSessionsHandler {
40+
async fn handle(
41+
&self,
42+
args: &Value,
43+
connection_config: Option<&Value>,
44+
) -> Result<String, String> {
45+
let conn_id = get_connection_id(connection_config)?;
46+
let database = args.get("database").and_then(|v| v.as_str());
47+
let adapter = resolve_adapter(&conn_id).await?;
48+
let sessions = list_sessions_on_adapter(&adapter, database).await?;
49+
serde_json::to_string(&sessions).map_err(|e| e.to_string())
50+
}
51+
}
52+
53+
#[async_trait]
54+
impl CapabilityHandler for KillSessionHandler {
55+
async fn handle(
56+
&self,
57+
args: &Value,
58+
connection_config: Option<&Value>,
59+
) -> Result<String, String> {
60+
let conn_id = get_connection_id(connection_config)?;
61+
let session_id = args
62+
.get("session_id")
63+
.and_then(|v| v.as_str())
64+
.ok_or_else(|| "Missing 'session_id' argument".to_string())?;
65+
let adapter = resolve_adapter(&conn_id).await?;
66+
kill_session_on_adapter(&adapter, session_id).await?;
67+
serde_json::to_string(&json!({ "status": "ok", "session_id": session_id }))
68+
.map_err(|e| e.to_string())
69+
}
70+
}
71+
72+
#[async_trait]
73+
impl CapabilityHandler for GetSlowQueriesHandler {
74+
async fn handle(
75+
&self,
76+
args: &Value,
77+
connection_config: Option<&Value>,
78+
) -> Result<String, String> {
79+
let conn_id = get_connection_id(connection_config)?;
80+
let database = args.get("database").and_then(|v| v.as_str());
81+
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|n| n as u32);
82+
let adapter = resolve_adapter(&conn_id).await?;
83+
let slow = get_slow_queries_on_adapter(&adapter, database, limit).await?;
84+
serde_json::to_string(&slow).map_err(|e| e.to_string())
85+
}
86+
}
87+
88+
#[async_trait]
89+
impl CapabilityHandler for GrantPrivilegeHandler {
90+
async fn handle(
91+
&self,
92+
args: &Value,
93+
connection_config: Option<&Value>,
94+
) -> Result<String, String> {
95+
let conn_id = get_connection_id(connection_config)?;
96+
let privilege = args
97+
.get("privilege")
98+
.and_then(|v| v.as_str())
99+
.ok_or_else(|| "Missing 'privilege' argument".to_string())?;
100+
let object = args
101+
.get("object")
102+
.and_then(|v| v.as_str())
103+
.ok_or_else(|| "Missing 'object' argument".to_string())?;
104+
let grantee = args
105+
.get("grantee")
106+
.and_then(|v| v.as_str())
107+
.ok_or_else(|| "Missing 'grantee' argument".to_string())?;
108+
let adapter = resolve_adapter(&conn_id).await?;
109+
grant_privilege_on_adapter(&adapter, privilege, object, grantee).await?;
110+
serde_json::to_string(&json!({ "status": "ok" })).map_err(|e| e.to_string())
111+
}
112+
}
113+
114+
#[async_trait]
115+
impl CapabilityHandler for RevokePrivilegeHandler {
116+
async fn handle(
117+
&self,
118+
args: &Value,
119+
connection_config: Option<&Value>,
120+
) -> Result<String, String> {
121+
let conn_id = get_connection_id(connection_config)?;
122+
let privilege = args
123+
.get("privilege")
124+
.and_then(|v| v.as_str())
125+
.ok_or_else(|| "Missing 'privilege' argument".to_string())?;
126+
let object = args
127+
.get("object")
128+
.and_then(|v| v.as_str())
129+
.ok_or_else(|| "Missing 'object' argument".to_string())?;
130+
let grantee = args
131+
.get("grantee")
132+
.and_then(|v| v.as_str())
133+
.ok_or_else(|| "Missing 'grantee' argument".to_string())?;
134+
let adapter = resolve_adapter(&conn_id).await?;
135+
revoke_privilege_on_adapter(&adapter, privilege, object, grantee).await?;
136+
serde_json::to_string(&json!({ "status": "ok" })).map_err(|e| e.to_string())
137+
}
138+
}
139+
140+
// ---------------------------------------------------------------------------
141+
// Registration
142+
// ---------------------------------------------------------------------------
143+
144+
fn connection_id_schema() -> Value {
145+
json!({
146+
"type": "string",
147+
"description": "The connection alias to use (e.g. 'mac-postgresql'). Use sqlkit__list_connections to see available connections."
148+
})
149+
}
150+
151+
pub(crate) fn register_dba_tools(reg: &mut CapabilityRegistry) {
152+
reg.register(Capability {
153+
name: "sqlkit__list_sessions",
154+
description: "List active database sessions/connections: id, user, database, state, and running query. Use to see who is connected and what they are running.",
155+
handler: Arc::new(ListSessionsHandler),
156+
input_schema: json!({"type": "object", "properties": {
157+
"connection_id": connection_id_schema(),
158+
"database": {"type": "string", "description": "Database name (optional)"}
159+
}, "required": ["connection_id"]}),
160+
risk_level: RiskLevel::Safe,
161+
required_permission: "read",
162+
source_kind: SourceKind::SqlDatabase,
163+
tags: &["agent"],
164+
parallel_ok: true,
165+
});
166+
167+
reg.register(Capability {
168+
name: "sqlkit__kill_session",
169+
description: "Terminate a database session by its session id (PostgreSQL PID for postgres). DANGEROUS: kills an in-flight query/connection. Requires Full Access in Settings → MCP Bridge.",
170+
handler: Arc::new(KillSessionHandler),
171+
input_schema: json!({"type": "object", "properties": {
172+
"connection_id": connection_id_schema(),
173+
"session_id": {"type": "string", "description": "Session id to terminate (numeric; for PostgreSQL this is the backend PID)"}
174+
}, "required": ["connection_id", "session_id"]}),
175+
risk_level: RiskLevel::Elevated,
176+
required_permission: "create",
177+
source_kind: SourceKind::SqlDatabase,
178+
tags: &["agent"],
179+
parallel_ok: false,
180+
});
181+
182+
reg.register(Capability {
183+
name: "sqlkit__get_slow_queries",
184+
description: "List currently slow-running queries (or cached slow query statistics) on the server: duration, user, and query text. Use when investigating performance issues.",
185+
handler: Arc::new(GetSlowQueriesHandler),
186+
input_schema: json!({"type": "object", "properties": {
187+
"connection_id": connection_id_schema(),
188+
"database": {"type": "string", "description": "Database name (optional)"},
189+
"limit": {"type": "integer", "description": "Maximum number of queries to return (optional, default 20)"}
190+
}, "required": ["connection_id"]}),
191+
risk_level: RiskLevel::Safe,
192+
required_permission: "read",
193+
source_kind: SourceKind::SqlDatabase,
194+
tags: &["agent"],
195+
parallel_ok: true,
196+
});
197+
198+
reg.register(Capability {
199+
name: "sqlkit__grant_privilege",
200+
description: "Grant a privilege (e.g. SELECT, INSERT) on an object (e.g. a table) to a user/role. Requires Full Access in Settings → MCP Bridge.",
201+
handler: Arc::new(GrantPrivilegeHandler),
202+
input_schema: json!({"type": "object", "properties": {
203+
"connection_id": connection_id_schema(),
204+
"privilege": {"type": "string", "description": "Privilege(s) to grant, e.g. SELECT, INSERT, or a comma-separated list"},
205+
"object": {"type": "string", "description": "Object to grant on, e.g. public.users or db.table"},
206+
"grantee": {"type": "string", "description": "User or role to grant to"}
207+
}, "required": ["connection_id", "privilege", "object", "grantee"]}),
208+
risk_level: RiskLevel::Elevated,
209+
required_permission: "create",
210+
source_kind: SourceKind::SqlDatabase,
211+
tags: &["agent"],
212+
parallel_ok: false,
213+
});
214+
215+
reg.register(Capability {
216+
name: "sqlkit__revoke_privilege",
217+
description: "Revoke a privilege (e.g. SELECT, INSERT) on an object from a user/role. Requires Full Access in Settings → MCP Bridge.",
218+
handler: Arc::new(RevokePrivilegeHandler),
219+
input_schema: json!({"type": "object", "properties": {
220+
"connection_id": connection_id_schema(),
221+
"privilege": {"type": "string", "description": "Privilege(s) to revoke, e.g. SELECT, INSERT, or a comma-separated list"},
222+
"object": {"type": "string", "description": "Object to revoke on, e.g. public.users or db.table"},
223+
"grantee": {"type": "string", "description": "User or role to revoke from"}
224+
}, "required": ["connection_id", "privilege", "object", "grantee"]}),
225+
risk_level: RiskLevel::Elevated,
226+
required_permission: "create",
227+
source_kind: SourceKind::SqlDatabase,
228+
tags: &["agent"],
229+
parallel_ok: false,
230+
});
231+
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
237+
#[tokio::test]
238+
async fn list_sessions_missing_config() {
239+
let err = ListSessionsHandler
240+
.handle(&json!({}), None)
241+
.await
242+
.unwrap_err();
243+
assert!(err.contains("connection_id"), "got: {}", err);
244+
assert!(err.contains("Settings → MCP Bridge"), "got: {}", err);
245+
}
246+
247+
#[tokio::test]
248+
async fn kill_session_missing_config() {
249+
let err = KillSessionHandler
250+
.handle(&json!({ "session_id": "123" }), None)
251+
.await
252+
.unwrap_err();
253+
assert!(err.contains("connection_id"), "got: {}", err);
254+
assert!(err.contains("Settings → MCP Bridge"), "got: {}", err);
255+
}
256+
257+
#[tokio::test]
258+
async fn kill_session_rejects_missing_session_id() {
259+
let config = json!({ "connectionId": "conn-1" });
260+
let err = KillSessionHandler
261+
.handle(&json!({}), Some(&config))
262+
.await
263+
.unwrap_err();
264+
assert!(err.contains("Missing 'session_id'"), "got: {}", err);
265+
}
266+
267+
#[tokio::test]
268+
async fn get_slow_queries_missing_config() {
269+
let err = GetSlowQueriesHandler
270+
.handle(&json!({}), None)
271+
.await
272+
.unwrap_err();
273+
assert!(err.contains("connection_id"), "got: {}", err);
274+
assert!(err.contains("Settings → MCP Bridge"), "got: {}", err);
275+
}
276+
277+
#[tokio::test]
278+
async fn grant_privilege_missing_config() {
279+
let err = GrantPrivilegeHandler
280+
.handle(
281+
&json!({ "privilege": "SELECT", "object": "users", "grantee": "app" }),
282+
None,
283+
)
284+
.await
285+
.unwrap_err();
286+
assert!(err.contains("connection_id"), "got: {}", err);
287+
assert!(err.contains("Settings → MCP Bridge"), "got: {}", err);
288+
}
289+
290+
#[tokio::test]
291+
async fn grant_privilege_rejects_missing_privilege() {
292+
let config = json!({ "connectionId": "conn-1" });
293+
let err = GrantPrivilegeHandler
294+
.handle(
295+
&json!({ "object": "users", "grantee": "app" }),
296+
Some(&config),
297+
)
298+
.await
299+
.unwrap_err();
300+
assert!(err.contains("Missing 'privilege'"), "got: {}", err);
301+
}
302+
303+
#[tokio::test]
304+
async fn grant_privilege_rejects_missing_object() {
305+
let config = json!({ "connectionId": "conn-1" });
306+
let err = GrantPrivilegeHandler
307+
.handle(
308+
&json!({ "privilege": "SELECT", "grantee": "app" }),
309+
Some(&config),
310+
)
311+
.await
312+
.unwrap_err();
313+
assert!(err.contains("Missing 'object'"), "got: {}", err);
314+
}
315+
316+
#[tokio::test]
317+
async fn grant_privilege_rejects_missing_grantee() {
318+
let config = json!({ "connectionId": "conn-1" });
319+
let err = GrantPrivilegeHandler
320+
.handle(
321+
&json!({ "privilege": "SELECT", "object": "users" }),
322+
Some(&config),
323+
)
324+
.await
325+
.unwrap_err();
326+
assert!(err.contains("Missing 'grantee'"), "got: {}", err);
327+
}
328+
329+
#[tokio::test]
330+
async fn revoke_privilege_missing_config() {
331+
let err = RevokePrivilegeHandler
332+
.handle(
333+
&json!({ "privilege": "SELECT", "object": "users", "grantee": "app" }),
334+
None,
335+
)
336+
.await
337+
.unwrap_err();
338+
assert!(err.contains("connection_id"), "got: {}", err);
339+
assert!(err.contains("Settings → MCP Bridge"), "got: {}", err);
340+
}
341+
342+
#[tokio::test]
343+
async fn revoke_privilege_rejects_missing_privilege() {
344+
let config = json!({ "connectionId": "conn-1" });
345+
let err = RevokePrivilegeHandler
346+
.handle(
347+
&json!({ "object": "users", "grantee": "app" }),
348+
Some(&config),
349+
)
350+
.await
351+
.unwrap_err();
352+
assert!(err.contains("Missing 'privilege'"), "got: {}", err);
353+
}
354+
355+
#[tokio::test]
356+
async fn revoke_privilege_rejects_missing_object() {
357+
let config = json!({ "connectionId": "conn-1" });
358+
let err = RevokePrivilegeHandler
359+
.handle(
360+
&json!({ "privilege": "SELECT", "grantee": "app" }),
361+
Some(&config),
362+
)
363+
.await
364+
.unwrap_err();
365+
assert!(err.contains("Missing 'object'"), "got: {}", err);
366+
}
367+
368+
#[tokio::test]
369+
async fn revoke_privilege_rejects_missing_grantee() {
370+
let config = json!({ "connectionId": "conn-1" });
371+
let err = RevokePrivilegeHandler
372+
.handle(
373+
&json!({ "privilege": "SELECT", "object": "users" }),
374+
Some(&config),
375+
)
376+
.await
377+
.unwrap_err();
378+
assert!(err.contains("Missing 'grantee'"), "got: {}", err);
379+
}
380+
}

src-tauri/src/capabilities/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod commands;
2+
pub mod dba;
23
pub mod mysql;
34
pub mod postgres;
45
pub mod sql;

0 commit comments

Comments
 (0)