Skip to content
26 changes: 26 additions & 0 deletions .impeccable/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "side-tab",
"value": "*",
"files": [
"src/components/agent-message-bubble.vue"
],
"createdAt": "2026-10-27T07:14:06.715Z",
"reason": "Agent review: this neutral border marks semantic Markdown blockquotes, not decorative card accents."
},
{
"rule": "bounce-easing",
"value": "typing-bounce",
"files": [
"src/components/agent-message-bubble.vue"
],
"createdAt": "2026-10-27T07:14:06.764Z",
"reason": "Agent review: the existing typing dots pulse from scale 0 to 1 with ease-in-out; there is no bounce, overshoot, or elastic easing."
}
]
}
}
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,6 @@ Check each affected surface. Documentation-only changes need source, link, and i

### AI permission controls

Connection dialogs expose a collapsed AI permissions section shared by Data Studio and external MCP. Default to system inheritance, show effective allowed actions, and reveal per-action Inherit / Allow / Deny under Customize. The system settings page edits action defaults, without a separate connection allowlist or MCP exposure switch. Preserve shared Select, Label, Button, neutral surfaces, localized English/Chinese copy, keyboard focus, and loading/error/retry states. See [AI permissions](docs/AI_PERMISSIONS.md) for enforcement and migration behavior.
Per-connection permission editing uses the existing local Ultimate gate and inline upgrade prompt; Community connection edits preserve saved permission decisions. Connection dialogs group the agent prompt with a collapsed AI permissions section shared by Data Studio and external MCP, separate from SSL and SSH tunnel blocks. Default to system inheritance, show effective allowed actions, and offer visible radio choices for access levels, including Custom. A short description below the option group reflects the selected access level. Custom reveals per-action switches whose labels show Use default (decision) for inherited actions and the explicit decision otherwise. Presets and custom editing change the same permission values and do not form separate policy layers. The system settings page edits action defaults, without a separate connection allowlist or MCP exposure switch. Separate shared AI defaults from MCP service settings using the existing settings cards. Use switches for binary actions, and preserve shared RadioGroup, Switch, Label, Button, neutral surfaces, localized English/Chinese copy, keyboard focus, and loading/error/retry states. See [AI permissions](docs/AI_PERMISSIONS.md) for enforcement and migration behavior.

Data Studio tool outcomes remain visible outside collapsible result details. Policy blocks use a local destructive notice naming the connection, required action, and recorded permission source; user rejection uses warning feedback and explicit rejection copy. Neither uses a success verb. A blocked conversation shows a paused status near the composer. Review permissions edits the blocked connection using the existing connection permission control, including inherited denials, with Save and Back to chat in one footer row. Global defaults remain in Settings. Retry request starts a fresh checked turn. Keep these notices and outcomes when restoring history; an unknown or interrupted outcome must not appear successful.
20 changes: 20 additions & 0 deletions docs/AI_PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,23 @@ Agent executors are bound to a conversation and its connection/source identities
Preparation rejects denied calls before displaying confirmation. A queued call whose confirmation requirements become stricter returns to the existing approval flow. Permission denials are terminal and do not trigger transport retries; approval never bypasses a later denial. Rechecks occur at dispatch and do not cancel operations already dispatched or erase context already sent to a model.

The host-hook API is introduced by [data-studio-agent #35](https://github.com/geek-fun/data-studio-agent/pull/35). Merge that runtime implementation before merging its consumers. Keep the exact compatible revision until a release containing these hooks exists; after the runtime is merged and released, update both the manifest and lockfile to that release. Older release tags do not provide this API.

## Permission controls

Settings separate MCP service controls from the AI defaults shared by Data Studio and external MCP clients. Access levels use the existing settings radio pattern; a short description below the option group updates with the selected access level. Custom is an access choice that reveals individual actions; it edits the same values as the presets. Choosing Custom alone does not change permissions.

Connection dialogs group the agent prompt with the AI permissions section, separate from the SSL and SSH tunnel blocks. They default to system inheritance. Custom actions use switches: inherited actions show Use default (with the current decision), explicit actions show their decision with the connection source, and toggling sets an explicit decision. Within the connection section, saved custom combinations show their action controls automatically. Choosing a preset sets explicit decisions for every action; choosing Use system default clears them.

Editing AI permission defaults and per-connection decisions requires the existing local Ultimate entitlement, including eligible version-locked releases. Community users see the existing upgrade prompt in connection dialogs. Ordinary connection edits preserve saved decisions. Permission cleanup after deletion remains available only when the connection no longer exists; it cannot reset the permissions of a saved connection.

## Blocked actions and conversation history

Ask and Auto both run permitted reads automatically. Ask requests approval for ordinary data writes; Auto runs them automatically. Both modes request approval for permitted deletes and structure/administration changes unless a saved confirmation rule applies. The application displays the approval card when the agent submits a tool call; model instructions do not replace that gate. Approval does not grant an action denied by the connection chain or conversation restrictions.

A policy denial records the tool result and pauses the agent through the existing runtime cancellation boundary. The host persists the conversation as blocked and reports that state after the run has settled. Already dispatched work is not undone. Other unresolved calls show an unavailable outcome instead of success. Explicit user rejection remains distinct from a policy block.

Each denied call retains a visible notice with its connection, required action, and decision source when recorded. For connection access denials, Review permissions always edits the blocked connection, including when its denied decision is inherited from system defaults. It never changes global defaults from chat. Conversation restrictions and saved confirmation rules retain their own recovery controls. Save and Back to chat share one dialog footer row. Saving never executes the blocked action. Retry request starts a new agent turn with the original tool arguments, fresh permissions, and normal confirmation; it does not replay earlier successful work automatically or bypass the application gate.

History loads persisted tool-call statuses and correlated results rather than assuming every call succeeded. Denials and failures remain visible after reopening. Interrupted or missing outcomes are shown as unavailable; legacy records whose permission source was never recorded retain a generic permission notice.

Each agent turn receives a backend-resolved snapshot of current effective actions for its attached connections, including management access. This planning context does not replace live dispatch authorization. Historical denials do not establish current access after a permission change, and an explicit retry must not be reported as failed without a new tool result. System AI defaults are application policy, not operating-system or database privileges. Earlier blocked calls remain labeled as previous attempts, with raw diagnostics collapsed behind their structured notice.
218 changes: 218 additions & 0 deletions src-tauri/src/agent/events.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
use std::sync::Mutex;

use data_studio_agent::traits::{CancelMap, EventEmitter};
use serde_json::Value;

use crate::ai_permissions::AccessDenial;

pub(crate) struct AgentRunEmitter<E> {
pub inner: E,
pub cancel_map: CancelMap,
pub blocked: Mutex<Option<Value>>,
}

impl<E: EventEmitter> EventEmitter for AgentRunEmitter<E> {
fn emit(&self, event: &str, mut payload: Value) {
if event == "agent-loop-tool-result" && payload["denied"] == true {
let denial = AccessDenial::from_message(
payload["envelope"]["summary"]
.as_str()
.unwrap_or("Access denied"),
);
payload["envelope"]["summary"] = Value::String(denial.message.clone());
payload["denial"] = serde_json::to_value(&denial).unwrap();
if denial.kind != "user" {
let mut blocked = self
.blocked
.lock()
.unwrap_or_else(|error| error.into_inner());
if blocked.is_none() {
*blocked = Some(payload.clone());
if let Some(session) = payload["session_id"].as_str() {
// Use the runtime's cancellation boundary after it records the denied result.
let _ = data_studio_agent::loop_runner::cancel_agent_loop(
session,
&self.cancel_map,
);
}
}
}
}
let blocked = self
.blocked
.lock()
.unwrap_or_else(|error| error.into_inner())
.is_some();
if blocked
&& (event == "agent-loop-done"
|| event == "agent-loop-iteration"
|| (event == "agent-loop-error" && payload["error"] == "cancelled"))
{
return;
}
self.inner.emit(event, payload);
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::{collections::HashMap, sync::Arc};

#[derive(Default)]
struct Events(Mutex<Vec<(String, Value)>>);
impl EventEmitter for Events {
fn emit(&self, event: &str, payload: Value) {
self.0.lock().unwrap().push((event.into(), payload));
}
}

#[test]
fn permission_denial_cancels_further_work_without_hiding_the_result() {
let (tx, mut rx) = tokio::sync::oneshot::channel();
let emitter = AgentRunEmitter {
inner: Events::default(),
cancel_map: Arc::new(Mutex::new(HashMap::from([("chat".into(), tx)]))),
blocked: Mutex::new(None),
};
let denial = AccessDenial::permission(
crate::ai_permissions::Permission::Manage,
"db",
"connection",
);
emitter.emit("agent-loop-tool-result", json!({"session_id":"chat","tool_call_id":"call","denied":true,"envelope":{"summary":denial.encode()}}));
assert_eq!(rx.try_recv(), Ok(()));
emitter.emit("agent-loop-error", json!({"error":"cancelled"}));
emitter.emit("agent-loop-iteration", json!({}));
let events = emitter.inner.0.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].1["denial"]["source"], "connection");
assert_eq!(events[0].1["envelope"]["summary"], denial.message);
assert!(emitter.blocked.lock().unwrap().is_some());
}

#[test]
fn user_rejection_does_not_become_a_permission_block() {
let emitter = AgentRunEmitter {
inner: Events::default(),
cancel_map: Arc::new(Mutex::new(HashMap::new())),
blocked: Mutex::new(None),
};
emitter.emit(
"agent-loop-tool-result",
json!({"denied":true,"envelope":{"summary":"Tool call was not approved"}}),
);
assert!(emitter.blocked.lock().unwrap().is_none());
assert_eq!(
emitter.inner.0.lock().unwrap()[0].1["denial"]["kind"],
"user"
);
}

#[tokio::test]
async fn denied_preflight_persists_result_and_stops_before_another_model_turn() {
use async_trait::async_trait;
use data_studio_agent::{
storage::{db::AgentDb, session_store::SqliteSessionStore},
tool_executor::{ToolCallPreparation, ToolEnvelope, ToolExecutor},
};
use std::sync::atomic::{AtomicUsize, Ordering};

struct Deny;
#[async_trait]
impl ToolExecutor for Deny {
fn prepare(
&self,
_: &str,
_: &Value,
_: &Value,
_: &HashMap<String, Value>,
) -> Result<ToolCallPreparation, String> {
Err(AccessDenial::permission(
crate::ai_permissions::Permission::Manage,
"db",
"system",
)
.encode())
}
async fn execute(&self, _: &str, _: &Value, _: &Value) -> Result<ToolEnvelope, String> {
panic!("a blocked action must not execute")
}
}

let requests = Arc::new(AtomicUsize::new(0));
let count = requests.clone();
let response = format!(
"data: {}\n\ndata: {}\n\ndata: [DONE]\n\n",
json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call","type":"function","function":{"name":"sqlkit__execute_ddl","arguments":"{\"connection_id\":\"db\",\"sql\":\"CREATE TABLE example(id INT)\"}"}}]}}]}),
json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]})
);
let router = axum::Router::new().route(
"/v1/chat/completions",
axum::routing::post(move || {
count.fetch_add(1, Ordering::SeqCst);
let response = response.clone();
async move { ([("content-type", "text/event-stream")], response) }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let db = AgentDb(Arc::new(Mutex::new(
rusqlite::Connection::open_in_memory().unwrap(),
)));
data_studio_agent::storage::db::migrate(&db).unwrap();
db.0.lock().unwrap().execute("INSERT INTO agent_sessions (id,title,created_at,updated_at) VALUES ('chat','Chat',0,0)", []).unwrap();
let cancel_map = Arc::new(Mutex::new(HashMap::new()));
let emitter = AgentRunEmitter {
inner: Events::default(),
cancel_map: cancel_map.clone(),
blocked: Mutex::new(None),
};
// An Anthropic-family model keeps token accounting on the cheap
// character heuristic; the OpenAI tiktoken encoders are rebuilt per
// message and can blow the loop timeout on CI runners.
let settings = json!({"baseUrl":format!("http://{address}"),"model":"claude-3-5-haiku","apiKey":"synthetic","proxyMode":"none","tools":[{"type":"function","function":{"name":"sqlkit__execute_ddl","parameters":{"type":"object"}}}],"maxIterations":3});
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
data_studio_agent::loop_runner::run_agent_loop(
"chat",
"Create example",
&settings,
&SqliteSessionStore::new(db.clone()),
&emitter,
&Deny,
HashMap::from([("db".into(), json!({"connectionId":"db"}))]),
Value::Null,
&Arc::new(Mutex::new(HashMap::new())),
&cancel_map,
&|_| false,
&|_, _| false,
),
)
.await
.unwrap();
server.abort();
assert_eq!(result.unwrap_err(), "cancelled");
assert_eq!(requests.load(Ordering::SeqCst), 1);
assert!(emitter.blocked.lock().unwrap().is_some());
let conn = db.0.lock().unwrap();
let status: String = conn
.query_row(
"SELECT status FROM agent_tool_calls WHERE id='call'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "denied");
let result_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM agent_messages WHERE role='tool'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(result_count, 1);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/agent/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// App-specific agent modules — shared logic is in data-studio-agent-lib.
pub(crate) mod events;
pub mod executor;
pub mod query_history;
pub mod session_store;
Loading
Loading