430 lines
17 KiB
Rust
430 lines
17 KiB
Rust
use std::sync::Arc;
|
|
|
|
use anyhow::{Result, bail};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::SqlitePool;
|
|
use tracing::info;
|
|
|
|
pub use crate::db::tool_permission_groups::ToolPermissionGroup;
|
|
use crate::approval::{ApprovalManager, RuleAction};
|
|
use crate::tools::fs::{canonicalize_for_policy, path_under};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct RunContext {
|
|
security_group: Option<String>,
|
|
#[serde(default)]
|
|
pub system_prompt: Vec<String>,
|
|
#[serde(default)]
|
|
pub allow_fs_writes: Vec<String>,
|
|
/// Extra directories/files granted read-only access (beyond the working directory,
|
|
/// `docs/`, `skills/`, and everything in `allow_fs_writes`, which is readable too).
|
|
#[serde(default)]
|
|
pub allow_fs_reads: Vec<String>,
|
|
/// Project root (agent path `projects/{owner}/{slug}`) when this is a project
|
|
/// session, `None` otherwise. The session working directory is always the user's
|
|
/// home (`~`); the agent references project files via this absolute agent path,
|
|
/// which `UserFs` routes to the per-member bind mount. Used to resolve
|
|
/// `__PROJECT_ROOT__` placeholders in an agent's `inject_memory` paths.
|
|
#[serde(default)]
|
|
pub project_root: Option<String>,
|
|
}
|
|
|
|
impl RunContext {
|
|
pub fn with_security_group(security_group: Option<String>) -> Self {
|
|
Self { security_group, ..Default::default() }
|
|
}
|
|
|
|
pub fn to_db(&self) -> String {
|
|
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
|
|
}
|
|
|
|
pub fn from_db(s: &str) -> Option<Self> {
|
|
if s.is_empty() { return None; }
|
|
serde_json::from_str(s).ok()
|
|
}
|
|
|
|
/// Permission group ID for approval rule lookup.
|
|
pub fn tool_group_id(&self) -> Option<&str> {
|
|
self.security_group.as_deref()
|
|
}
|
|
|
|
/// Combined system prompt fragments to inject as dynamic context, or None if empty.
|
|
pub fn extra_system_prompt(&self) -> Option<String> {
|
|
if self.system_prompt.is_empty() { return None; }
|
|
Some(self.system_prompt.join("\n\n"))
|
|
}
|
|
|
|
/// True if writing to `path` is pre-authorized by this RunContext.
|
|
/// Entries in `allow_fs_writes` are resolved against Skald's process cwd,
|
|
/// so relative entries like `"data"` are treated as relative to the process cwd.
|
|
/// Paths are canonicalized first (resolving `..`/symlinks), then matched as
|
|
/// exact file OR recursive directory prefix.
|
|
pub fn is_write_allowed(&self, path: &str) -> bool {
|
|
if self.allow_fs_writes.is_empty() { return false; }
|
|
let wd = std::env::current_dir().unwrap_or_default();
|
|
let canon = canonicalize_for_policy(path, &wd);
|
|
self.allow_fs_writes.iter().any(|entry| {
|
|
path_under(&canon, &canonicalize_for_policy(entry, &wd))
|
|
})
|
|
}
|
|
|
|
/// True if reading `path` is pre-authorized by this RunContext.
|
|
/// Read access is granted (no approval prompt) for: the process working directory
|
|
/// itself, its `docs/` and `skills/` subtrees (always-safe baseline), any
|
|
/// `allow_fs_reads` entry, and anything writable (write implies read). All paths
|
|
/// are canonicalized first so `..`/symlink escapes cannot widen the grant.
|
|
///
|
|
/// Note: this only relaxes a `Require` decision to `Allow` — an explicit `Deny`
|
|
/// rule (e.g. on `secrets/`) still wins, because the approval engine is consulted
|
|
/// first and `Deny` is never overridden by this fast-path.
|
|
pub fn is_read_allowed(&self, path: &str) -> bool {
|
|
let wd = std::env::current_dir().unwrap_or_default();
|
|
let canon = canonicalize_for_policy(path, &wd);
|
|
|
|
let mut roots: Vec<std::path::PathBuf> = vec![
|
|
canonicalize_for_policy(".", &wd), // process working directory
|
|
canonicalize_for_policy("docs", &wd),
|
|
canonicalize_for_policy("skills", &wd),
|
|
];
|
|
roots.extend(self.allow_fs_reads.iter().map(|e| canonicalize_for_policy(e, &wd)));
|
|
roots.extend(self.allow_fs_writes.iter().map(|e| canonicalize_for_policy(e, &wd)));
|
|
|
|
roots.iter().any(|root| path_under(&canon, root))
|
|
}
|
|
}
|
|
|
|
/// Outcome of validating a client-supplied [`RunContext`] against the caller's role.
|
|
pub enum RunContextDecision {
|
|
/// Apply this (possibly sanitized) run-context to the session.
|
|
Apply(Option<RunContext>),
|
|
/// The requested security-group is not in the role's allowed set (→ 403); the
|
|
/// string is the offending group id.
|
|
Forbidden(String),
|
|
}
|
|
|
|
/// Gate a client-supplied run-context by the caller's role, closing two holes at
|
|
/// once (§0.1 — enforce server-side, never trust the client):
|
|
///
|
|
/// - **Group governance**: a non-admin may only select a security-group in its
|
|
/// role's effective set ([`crate::db::roles::role_allows_group`]); anything else
|
|
/// is [`RunContextDecision::Forbidden`].
|
|
/// - **fs escalation**: for a non-admin every other `RunContext` field
|
|
/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `project_root`) is
|
|
/// **discarded** — the client can set the permission group, nothing more. A rich
|
|
/// run-context (a project's) is resolved server-side, never through this path.
|
|
///
|
|
/// `admin` is trusted and passes through unchanged. `None` (clear) is always
|
|
/// allowed and falls back to the role's default group at session build.
|
|
pub async fn validate_run_context_for_role(
|
|
registry_pool: &SqlitePool,
|
|
role_id: &str,
|
|
incoming: Option<RunContext>,
|
|
) -> Result<RunContextDecision> {
|
|
if role_id == crate::db::roles::ADMIN_ROLE_ID {
|
|
return Ok(RunContextDecision::Apply(incoming));
|
|
}
|
|
let Some(rc) = incoming else {
|
|
return Ok(RunContextDecision::Apply(None));
|
|
};
|
|
match rc.tool_group_id() {
|
|
// A non-admin that names no group is treated as a clear (→ default group).
|
|
None => Ok(RunContextDecision::Apply(None)),
|
|
Some(group) => {
|
|
if crate::db::roles::role_allows_group(registry_pool, role_id, group).await? {
|
|
let group = group.to_string();
|
|
Ok(RunContextDecision::Apply(Some(RunContext::with_security_group(Some(group)))))
|
|
} else {
|
|
Ok(RunContextDecision::Forbidden(group.to_string()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct RunContextManager {
|
|
db: Arc<SqlitePool>,
|
|
approval: Arc<ApprovalManager>,
|
|
}
|
|
|
|
impl RunContextManager {
|
|
pub fn new(db: Arc<SqlitePool>, approval: Arc<ApprovalManager>) -> Self {
|
|
Self { db, approval }
|
|
}
|
|
|
|
/// Seeds the built-in "default" permission group and migrates legacy rules.
|
|
/// Safe to call at every startup (idempotent).
|
|
pub async fn seed_defaults(&self) -> Result<()> {
|
|
crate::db::tool_permission_groups::insert_or_ignore(
|
|
&self.db, "default", "Default", Some("Built-in default permission group"),
|
|
).await?;
|
|
|
|
let migrated = sqlx::query("UPDATE approval_rules SET group_id = 'default' WHERE group_id IS NULL")
|
|
.execute(self.db.as_ref())
|
|
.await
|
|
.map(|r| r.rows_affected())
|
|
.unwrap_or(0);
|
|
|
|
if migrated > 0 {
|
|
info!(%migrated, "run_context: migrated approval rules to 'default' group");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── ToolPermissionGroup CRUD ───────────────────────────────────────────────
|
|
|
|
pub async fn list_groups(&self) -> Result<Vec<ToolPermissionGroup>> {
|
|
crate::db::tool_permission_groups::list(&self.db).await
|
|
}
|
|
|
|
pub async fn get_group(&self, id: &str) -> Result<Option<ToolPermissionGroup>> {
|
|
crate::db::tool_permission_groups::get(&self.db, id).await
|
|
}
|
|
|
|
pub async fn create_group(
|
|
&self,
|
|
id: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<()> {
|
|
if id == "default" {
|
|
bail!("cannot create a permission group with reserved id 'default'");
|
|
}
|
|
crate::db::tool_permission_groups::insert(&self.db, id, name, description).await
|
|
}
|
|
|
|
pub async fn update_group(
|
|
&self,
|
|
id: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<bool> {
|
|
crate::db::tool_permission_groups::update(&self.db, id, name, description).await
|
|
}
|
|
|
|
pub async fn delete_group(&self, id: &str) -> Result<bool> {
|
|
if id == "default" {
|
|
bail!("cannot delete the built-in 'default' permission group");
|
|
}
|
|
crate::db::tool_permission_groups::delete(&self.db, id).await
|
|
}
|
|
|
|
/// Duplicates a permission group and all its rules atomically.
|
|
pub async fn duplicate_group(
|
|
&self,
|
|
source_id: &str,
|
|
new_id: &str,
|
|
new_name: &str,
|
|
) -> Result<()> {
|
|
if new_id == "default" {
|
|
bail!("cannot create a permission group with reserved id 'default'");
|
|
}
|
|
let source = crate::db::tool_permission_groups::get(&self.db, source_id).await?
|
|
.ok_or_else(|| anyhow::anyhow!("source group '{source_id}' not found"))?;
|
|
|
|
let mut tx = self.db.begin().await?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO tool_permission_groups (id, name, description) VALUES (?, ?, ?)",
|
|
)
|
|
.bind(new_id)
|
|
.bind(new_name)
|
|
.bind(source.description.as_deref())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO approval_rules \
|
|
(agent_id, source, tool_pattern, path_pattern, action, note, priority, group_id) \
|
|
SELECT agent_id, source, tool_pattern, path_pattern, action, note, priority, ? \
|
|
FROM approval_rules \
|
|
WHERE group_id = ?",
|
|
)
|
|
.bind(new_id)
|
|
.bind(source_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Tool visibility ────────────────────────────────────────────────────────
|
|
|
|
/// Returns the effective `RuleAction` for `tool_name` under the given permission group.
|
|
/// `run_context_id` now directly holds a `tool_permission_groups` id (the run_contexts
|
|
/// table indirection has been removed). Falls back to the `"default"` group when `None`.
|
|
pub async fn check_tool_visibility(
|
|
&self,
|
|
run_context_id: Option<&str>,
|
|
tool_name: &str,
|
|
) -> Option<RuleAction> {
|
|
let group_id = run_context_id.unwrap_or("default");
|
|
self.approval.check_tool_visibility(group_id, tool_name).await
|
|
}
|
|
|
|
// ── Session assignment ─────────────────────────────────────────────────────
|
|
|
|
/// Serialises `ctx` as JSON and stores it on the session row.
|
|
/// `None` clears the context (falls back to the default permission group).
|
|
pub async fn set_session_run_context(
|
|
&self,
|
|
session_id: i64,
|
|
ctx: Option<&RunContext>,
|
|
) -> Result<()> {
|
|
let json = ctx.map(|rc| rc.to_db());
|
|
sqlx::query("UPDATE chat_sessions SET run_context = ? WHERE id = ?")
|
|
.bind(json.as_deref())
|
|
.bind(session_id)
|
|
.execute(self.db.as_ref())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
/// Creates a fresh, uniquely-named temp directory for an fs test.
|
|
fn unique_tmp() -> PathBuf {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
|
let dir = std::env::temp_dir()
|
|
.join(format!("skald_rc_test_{}_{}", std::process::id(), nanos));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn read_allows_write_paths_and_extra_reads() {
|
|
let writable = unique_tmp();
|
|
let readable = unique_tmp();
|
|
std::fs::write(writable.join("w.txt"), "x").unwrap();
|
|
std::fs::write(readable.join("r.txt"), "x").unwrap();
|
|
|
|
let rc = RunContext {
|
|
allow_fs_writes: vec![writable.to_string_lossy().into_owned()],
|
|
allow_fs_reads: vec![readable.to_string_lossy().into_owned()],
|
|
..Default::default()
|
|
};
|
|
// write implies read
|
|
assert!(rc.is_read_allowed(writable.join("w.txt").to_str().unwrap()));
|
|
assert!(rc.is_write_allowed(writable.join("w.txt").to_str().unwrap()));
|
|
// read-only grant: readable but not writable
|
|
assert!(rc.is_read_allowed(readable.join("r.txt").to_str().unwrap()));
|
|
assert!(!rc.is_write_allowed(readable.join("r.txt").to_str().unwrap()));
|
|
|
|
std::fs::remove_dir_all(&writable).ok();
|
|
std::fs::remove_dir_all(&readable).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_resolves_parent_traversal() {
|
|
let wd = unique_tmp();
|
|
std::fs::create_dir_all(wd.join("docs")).unwrap();
|
|
std::fs::create_dir_all(wd.join("secrets")).unwrap();
|
|
std::fs::write(wd.join("secrets").join("s.txt"), "x").unwrap();
|
|
|
|
assert_eq!(
|
|
canonicalize_for_policy("docs/../secrets/s.txt", &wd),
|
|
canonicalize_for_policy("secrets/s.txt", &wd),
|
|
);
|
|
|
|
std::fs::remove_dir_all(&wd).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_resolves_symlink_escape() {
|
|
let wd = unique_tmp();
|
|
std::fs::create_dir_all(wd.join("docs")).unwrap();
|
|
std::fs::create_dir_all(wd.join("secrets")).unwrap();
|
|
std::fs::write(wd.join("secrets").join("s.txt"), "x").unwrap();
|
|
std::os::unix::fs::symlink(wd.join("secrets"), wd.join("docs").join("leak")).unwrap();
|
|
|
|
// A symlink docs/leak -> secrets must resolve to the real secrets path.
|
|
assert_eq!(
|
|
canonicalize_for_policy("docs/leak/s.txt", &wd),
|
|
canonicalize_for_policy("secrets/s.txt", &wd),
|
|
);
|
|
|
|
std::fs::remove_dir_all(&wd).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn write_allow_not_bypassed_by_traversal() {
|
|
let wd = unique_tmp();
|
|
std::fs::create_dir_all(wd.join("data")).unwrap();
|
|
std::fs::create_dir_all(wd.join("secrets")).unwrap();
|
|
|
|
let data_dir = wd.join("data").to_string_lossy().into_owned();
|
|
let rc = RunContext {
|
|
allow_fs_writes: vec![data_dir],
|
|
..Default::default()
|
|
};
|
|
// Writing into data/ is allowed...
|
|
assert!(rc.is_write_allowed(wd.join("data").join("new.txt").to_str().unwrap()));
|
|
// ...but data/../secrets/x escapes the grant and must NOT be allowed.
|
|
assert!(!rc.is_write_allowed(wd.join("data").join("..").join("secrets").join("x.txt").to_str().unwrap()));
|
|
|
|
std::fs::remove_dir_all(&wd).ok();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn validate_admin_passes_through_untouched() {
|
|
let path = unique_tmp().join("system.db");
|
|
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
|
let rc = RunContext {
|
|
security_group: Some("ops".into()),
|
|
allow_fs_writes: vec!["/etc".into()],
|
|
..Default::default()
|
|
};
|
|
match validate_run_context_for_role(&pool, "admin", Some(rc)).await.unwrap() {
|
|
RunContextDecision::Apply(Some(got)) => {
|
|
assert_eq!(got.tool_group_id(), Some("ops"));
|
|
assert_eq!(got.allow_fs_writes, vec!["/etc".to_string()]);
|
|
}
|
|
_ => panic!("admin must pass through unchanged"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn validate_non_admin_gates_group_and_strips_fs() {
|
|
let path = unique_tmp().join("system.db");
|
|
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
|
crate::db::roles::insert(&pool, "member", "Member", "default",
|
|
Some(r#"{"permission_groups":["ops"]}"#)).await.unwrap();
|
|
|
|
// Allowed group: kept, but every other field is discarded (fs hardening).
|
|
let rc = RunContext {
|
|
security_group: Some("ops".into()),
|
|
allow_fs_writes: vec!["/etc".into()],
|
|
system_prompt: vec!["ignore me".into()],
|
|
..Default::default()
|
|
};
|
|
match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() {
|
|
RunContextDecision::Apply(Some(got)) => {
|
|
assert_eq!(got.tool_group_id(), Some("ops"));
|
|
assert!(got.allow_fs_writes.is_empty());
|
|
assert!(got.system_prompt.is_empty());
|
|
}
|
|
_ => panic!("an allowed group must apply, sanitized"),
|
|
}
|
|
|
|
// A group outside the role's set is refused.
|
|
let rc = RunContext { security_group: Some("secret".into()), ..Default::default() };
|
|
match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() {
|
|
RunContextDecision::Forbidden(g) => assert_eq!(g, "secret"),
|
|
_ => panic!("a group outside the set must be forbidden"),
|
|
}
|
|
|
|
// Clearing is always allowed (falls back to the role default at build time).
|
|
match validate_run_context_for_role(&pool, "member", None).await.unwrap() {
|
|
RunContextDecision::Apply(None) => {}
|
|
_ => panic!("clear must be allowed"),
|
|
}
|
|
|
|
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
|
}
|
|
}
|