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:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user