feat(agent): add permission, sandbox and approval primitives for agent workbench

Introduces the SDK-free building blocks for a server-side AI agent:

- permission: tool risk classification (ReadOnly/Write/Execute) and the
  per-mode decision matrix (Default/AcceptEdits/FullAccess). Default mode
  auto-allows reads and requires approval for writes/executes.
- sandbox: per-session isolated working directory under
  data/agent_sessions/{id}/ plus path validation that confines every tool
  path argument within the session directory (rejects ../, absolute and
  symlink escapes), mirroring the AQBot security model.
- approval: process-global registry bridging the SSE approval round-trip via
  oneshot channels, so the permission callback can block until the user
  approves or denies a risky tool.

All three modules are unit-tested in isolation (17 tests) and carry no
dependency on the agent SDK; the HTTP layer maps their results onto the
SDK permission callback in a later commit.
This commit is contained in:
boos
2026-06-22 04:48:29 +08:00
parent 4a12610662
commit 4192798ceb
5 changed files with 464 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
//! Pending-approval registry for the Agent Workbench.
//!
//! When a session runs in a mode that requires approval for a risky tool, the
//! permission callback emits an `approval_request` SSE event and then blocks on
//! a [`tokio::sync::oneshot`] channel keyed by a freshly minted request id. The
//! `POST /api/agent/approve` endpoint looks the id up here and delivers the
//! user's decision, unblocking the agent loop.
//!
//! The registry is process-global and self-cleaning: a request is removed the
//! moment its decision is delivered, when it times out, or when the waiting side
//! is dropped.
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::oneshot;
/// User's decision on a pending tool approval.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalOutcome {
Approve,
Deny,
}
static REGISTRY: Lazy<Mutex<HashMap<String, oneshot::Sender<ApprovalOutcome>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
/// Register a new pending approval, returning the receiver the caller should
/// await. The returned id is what the frontend echoes back to `/approve`.
pub fn register(request_id: &str) -> oneshot::Receiver<ApprovalOutcome> {
let (tx, rx) = oneshot::channel();
let mut map = REGISTRY.lock().unwrap();
map.insert(request_id.to_string(), tx);
rx
}
/// Resolve a pending approval. Returns `true` if a waiter was found and
/// notified, `false` if the id was unknown (already resolved, timed out, or
/// belonged to another process instance).
pub fn resolve(request_id: &str, outcome: ApprovalOutcome) -> bool {
let sender = {
let mut map = REGISTRY.lock().unwrap();
map.remove(request_id)
};
match sender {
Some(tx) => tx.send(outcome).is_ok(),
None => false,
}
}
/// Drop a pending approval without delivering a decision (e.g. on timeout or
/// when the session ends). No-op if already resolved.
pub fn cancel(request_id: &str) {
let mut map = REGISTRY.lock().unwrap();
map.remove(request_id);
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn resolve_delivers_outcome_to_waiter() {
let rx = register("req-1");
assert!(resolve("req-1", ApprovalOutcome::Approve));
assert_eq!(rx.await.unwrap(), ApprovalOutcome::Approve);
}
#[tokio::test]
async fn resolve_unknown_id_returns_false() {
assert!(!resolve("does-not-exist", ApprovalOutcome::Deny));
}
#[tokio::test]
async fn cancel_removes_pending() {
let rx = register("req-2");
cancel("req-2");
// Sender dropped → receiver errors out.
assert!(rx.await.is_err());
assert!(!resolve("req-2", ApprovalOutcome::Approve));
}
#[tokio::test]
async fn double_resolve_only_first_wins() {
let _rx = register("req-3");
assert!(resolve("req-3", ApprovalOutcome::Approve));
assert!(!resolve("req-3", ApprovalOutcome::Deny));
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Agent Workbench: a general-purpose, server-side AI agent exposed over SSE.
//!
//! The agent loop is provided by the `open-agent-sdk` crate. This module owns
//! the BoosAPI-specific concerns layered on top of it:
//!
//! - [`permission`]: tool risk classification + the per-mode decision matrix.
//! - [`sandbox`]: per-session isolated working directory + path validation.
//! - [`approval`]: process-global registry bridging the SSE approval round-trip.
//!
//! The HTTP surface (SSE chat, approval callback, status) lives in
//! `crate::routes::agent`.
pub mod approval;
pub mod permission;
pub mod sandbox;
+172
View File
@@ -0,0 +1,172 @@
//! Tool risk classification and permission decision matrix for the Agent Workbench.
//!
//! Ported and adapted from the AQBot agent crate. Deliberately SDK-free so it
//! can be unit-tested in isolation; the route layer maps [`PermissionAction`]
//! onto the SDK's `PermissionDecision`.
use serde::{Deserialize, Serialize};
/// Risk level of a tool, inferred from its name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskLevel {
/// Reads only; never mutates the filesystem or runs commands.
ReadOnly,
/// Mutates files (write/edit/create/delete/...).
Write,
/// Executes arbitrary commands (bash/shell/...).
Execute,
}
/// Permission mode for an agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionMode {
/// Standard review: read auto-allowed, write/execute require approval.
Default,
/// Auto-accept edits: read + write auto-allowed, execute requires approval.
AcceptEdits,
/// Everything auto-allowed (no approvals).
FullAccess,
}
impl PermissionMode {
/// Parse a mode from a wire string. Unknown values fall back to the safest
/// mode ([`PermissionMode::Default`]).
pub fn from_wire(s: &str) -> Self {
match s {
"accept_edits" | "acceptEdits" => Self::AcceptEdits,
"full_access" | "fullAccess" => Self::FullAccess,
_ => Self::Default,
}
}
}
/// What the permission system decides for a given tool invocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionAction {
/// Allow without prompting the user.
AutoAllow,
/// Pause and require explicit user approval before running.
RequireApproval,
}
/// Classify a tool's risk level from its name (case-insensitive).
///
/// Conservative by construction: anything that looks like it executes a command
/// is [`RiskLevel::Execute`], anything that looks like it mutates state is
/// [`RiskLevel::Write`], and everything else is [`RiskLevel::ReadOnly`].
pub fn classify_tool_risk(tool_name: &str) -> RiskLevel {
let n = tool_name.to_lowercase();
if matches!(n.as_str(), "bash" | "shell" | "run_command" | "execute")
|| n.contains("exec")
|| n.contains("bash")
|| n.contains("shell")
|| n.contains("command")
{
return RiskLevel::Execute;
}
if matches!(
n.as_str(),
"write" | "edit" | "create" | "delete" | "rename" | "patch" | "move" | "mkdir" | "remove"
) || n.contains("write")
|| n.contains("edit")
|| n.contains("create")
|| n.contains("delete")
|| n.contains("patch")
|| n.contains("apply")
{
return RiskLevel::Write;
}
RiskLevel::ReadOnly
}
/// Decide what to do for a tool invocation given the session mode and the tool's
/// risk level.
pub fn decide_permission(mode: PermissionMode, risk: RiskLevel) -> PermissionAction {
match (mode, risk) {
(PermissionMode::Default, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
(PermissionMode::Default, _) => PermissionAction::RequireApproval,
(PermissionMode::AcceptEdits, RiskLevel::ReadOnly) => PermissionAction::AutoAllow,
(PermissionMode::AcceptEdits, RiskLevel::Write) => PermissionAction::AutoAllow,
(PermissionMode::AcceptEdits, RiskLevel::Execute) => PermissionAction::RequireApproval,
(PermissionMode::FullAccess, _) => PermissionAction::AutoAllow,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_execute_tools() {
for t in ["Bash", "bash", "shell", "run_command", "execute", "RunInTerminal"] {
assert_eq!(classify_tool_risk(t), RiskLevel::Execute, "tool={t}");
}
}
#[test]
fn classifies_write_tools() {
for t in ["Write", "Edit", "create_file", "delete_file", "patch", "ApplyPatch"] {
assert_eq!(classify_tool_risk(t), RiskLevel::Write, "tool={t}");
}
}
#[test]
fn classifies_read_tools() {
for t in ["Read", "Glob", "Grep", "WebFetch", "list_dir"] {
assert_eq!(classify_tool_risk(t), RiskLevel::ReadOnly, "tool={t}");
}
}
#[test]
fn default_mode_requires_approval_for_write_and_execute() {
assert_eq!(
decide_permission(PermissionMode::Default, RiskLevel::ReadOnly),
PermissionAction::AutoAllow
);
assert_eq!(
decide_permission(PermissionMode::Default, RiskLevel::Write),
PermissionAction::RequireApproval
);
assert_eq!(
decide_permission(PermissionMode::Default, RiskLevel::Execute),
PermissionAction::RequireApproval
);
}
#[test]
fn accept_edits_allows_write_but_not_execute() {
assert_eq!(
decide_permission(PermissionMode::AcceptEdits, RiskLevel::Write),
PermissionAction::AutoAllow
);
assert_eq!(
decide_permission(PermissionMode::AcceptEdits, RiskLevel::Execute),
PermissionAction::RequireApproval
);
}
#[test]
fn full_access_allows_everything() {
for r in [RiskLevel::ReadOnly, RiskLevel::Write, RiskLevel::Execute] {
assert_eq!(
decide_permission(PermissionMode::FullAccess, r),
PermissionAction::AutoAllow
);
}
}
#[test]
fn mode_parsing_falls_back_to_default() {
assert_eq!(PermissionMode::from_wire("accept_edits"), PermissionMode::AcceptEdits);
assert_eq!(PermissionMode::from_wire("full_access"), PermissionMode::FullAccess);
assert_eq!(PermissionMode::from_wire("garbage"), PermissionMode::Default);
assert_eq!(PermissionMode::from_wire(""), PermissionMode::Default);
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Per-session filesystem sandbox for the Agent Workbench.
//!
//! Every agent session is confined to an isolated working directory
//! (`data/agent_sessions/{session_id}/`). Tool path arguments are validated to
//! resolve *within* that directory before the tool is allowed to run, so one
//! tenant's agent can never read or write another tenant's files (or escape to
//! the host filesystem via `..`/symlinks/absolute paths).
//!
//! Adapted from the AQBot agent crate's `security.rs`. SDK-free: the route layer
//! turns an [`Err`] into a permission denial.
use serde_json::Value;
use std::path::{Path, PathBuf};
/// Root directory that holds all per-session sandboxes, relative to the server's
/// working directory.
pub const SESSIONS_ROOT: &str = "data/agent_sessions";
/// Compute the sandbox directory for a session id (not yet created on disk).
pub fn session_dir(session_id: &str) -> PathBuf {
Path::new(SESSIONS_ROOT).join(sanitize_session_id(session_id))
}
/// Create the sandbox directory for a session if missing, returning its
/// canonical absolute path (suitable for use as the agent cwd).
pub fn ensure_session_dir(session_id: &str) -> std::io::Result<PathBuf> {
let dir = session_dir(session_id);
std::fs::create_dir_all(&dir)?;
dir.canonicalize()
}
/// Strip anything that could let a session id traverse outside SESSIONS_ROOT.
/// Keeps only `[A-Za-z0-9_-]`; everything else becomes `_`.
fn sanitize_session_id(session_id: &str) -> String {
let cleaned: String = session_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
if cleaned.is_empty() {
"default".to_string()
} else {
cleaned
}
}
/// Validate that `path` (absolute or relative to `cwd`) resolves to a location
/// within `cwd`. Returns the resolved path on success, or an error message
/// describing the escape attempt.
///
/// For existing paths the target is canonicalized (resolving symlinks). For
/// not-yet-existing paths the nearest existing ancestor is canonicalized and
/// checked, so writes to new files inside the sandbox are permitted while writes
/// that would escape it are rejected.
pub fn validate_path_within_cwd(path: &str, cwd: &Path) -> Result<PathBuf, String> {
let cwd_canonical = cwd
.canonicalize()
.map_err(|e| format!("cannot resolve sandbox directory '{}': {e}", cwd.display()))?;
let target = Path::new(path);
let resolved = if target.is_absolute() {
target.to_path_buf()
} else {
cwd_canonical.join(target)
};
if resolved.exists() {
let canonical = resolved
.canonicalize()
.map_err(|e| format!("cannot resolve path '{path}': {e}"))?;
return if canonical.starts_with(&cwd_canonical) {
Ok(canonical)
} else {
Err(format!(
"path '{path}' resolves outside the session sandbox"
))
};
}
// Not-yet-existing path: walk up to the nearest existing ancestor.
let mut ancestor = resolved.parent();
while let Some(dir) = ancestor {
if dir.exists() {
let anc_canonical = dir
.canonicalize()
.map_err(|e| format!("cannot resolve ancestor of '{path}': {e}"))?;
return if anc_canonical.starts_with(&cwd_canonical) {
Ok(resolved)
} else {
Err(format!("path '{path}' is outside the session sandbox"))
};
}
ancestor = dir.parent();
}
Err(format!("cannot verify path '{path}' against the session sandbox"))
}
/// Check whether a tool's path-bearing arguments stay within the sandbox.
///
/// Returns `Ok(())` when the tool either takes no path arguments or all of its
/// path arguments resolve within `cwd`; returns `Err(reason)` on the first
/// argument that escapes. Bash/shell are intentionally not path-checked here —
/// they are gated by the approval flow and run with the sandbox dir as cwd.
pub fn check_tool_paths(tool_name: &str, input: &Value, cwd: &Path) -> Result<(), String> {
let n = tool_name.to_lowercase();
match n.as_str() {
"read" | "read_file" | "write" | "write_file" | "edit" | "edit_file" | "create"
| "create_file" | "delete" | "delete_file" | "rename" | "list_dir" | "listdir"
| "multiedit" => {
check_field(input, &["path", "file_path", "file"], cwd)?;
check_field(input, &["new_path", "destination", "dest"], cwd)?;
Ok(())
}
"glob" | "glob_search" | "grep" | "search" | "ripgrep" => {
// Optional explicit base path; the pattern itself is relative to cwd.
check_field(input, &["path", "dir", "directory"], cwd)?;
Ok(())
}
// Command execution and unknown tools: no path arg to validate here.
_ => Ok(()),
}
}
fn check_field(input: &Value, keys: &[&str], cwd: &Path) -> Result<(), String> {
for key in keys {
if let Some(p) = input.get(*key).and_then(|v| v.as_str()) {
validate_path_within_cwd(p, cwd)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tmp_sandbox() -> PathBuf {
let base = std::env::temp_dir().join(format!("agent_sb_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&base).unwrap();
base.canonicalize().unwrap()
}
#[test]
fn sanitizes_session_id() {
assert_eq!(sanitize_session_id("../../etc"), "______etc");
assert_eq!(sanitize_session_id("abc-123_DEF"), "abc-123_DEF");
assert_eq!(sanitize_session_id(""), "default");
assert_eq!(sanitize_session_id("a/b"), "a_b");
}
#[test]
fn allows_path_inside_sandbox() {
let sb = tmp_sandbox();
assert!(validate_path_within_cwd("notes.txt", &sb).is_ok());
assert!(validate_path_within_cwd("sub/dir/new.txt", &sb).is_ok());
}
#[test]
fn rejects_parent_traversal() {
let sb = tmp_sandbox();
assert!(validate_path_within_cwd("../escape.txt", &sb).is_err());
assert!(validate_path_within_cwd("../../etc/passwd", &sb).is_err());
}
#[test]
fn rejects_absolute_outside() {
let sb = tmp_sandbox();
assert!(validate_path_within_cwd("/etc/passwd", &sb).is_err());
}
#[test]
fn check_tool_paths_blocks_escaping_write() {
let sb = tmp_sandbox();
let bad = json!({"path": "../../secret.txt", "content": "x"});
assert!(check_tool_paths("write", &bad, &sb).is_err());
let ok = json!({"path": "out.txt", "content": "x"});
assert!(check_tool_paths("write", &ok, &sb).is_ok());
}
#[test]
fn check_tool_paths_ignores_bash() {
let sb = tmp_sandbox();
let input = json!({"command": "ls -la"});
assert!(check_tool_paths("bash", &input, &sb).is_ok());
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod announcement; pub mod announcement;
pub mod agent_workbench;
pub mod billing; pub mod billing;
pub mod builtin_keys; pub mod builtin_keys;
pub mod comfyui_process; pub mod comfyui_process;