feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
+376
View File
@@ -0,0 +1,376 @@
use std::path::PathBuf;
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>,
/// Working directory for tool calls. None means Skald's own process cwd.
#[serde(default)]
pub working_directory: 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"))
}
/// Effective working directory for this session.
/// Returns the configured path if set and non-empty, otherwise Skald's process cwd.
pub fn effective_working_dir(&self) -> PathBuf {
self.working_directory
.as_deref()
.filter(|d| !d.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
/// True if writing to `path` is pre-authorized by this RunContext.
/// Entries in `allow_fs_writes` are resolved against `effective_working_dir`,
/// so relative entries like `"data"` are treated as relative to the session WD.
/// 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 = self.effective_working_dir();
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 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 = self.effective_working_dir();
let canon = canonicalize_for_policy(path, &wd);
let mut roots: Vec<std::path::PathBuf> = vec![
canonicalize_for_policy(".", &wd), // working directory itself
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))
}
}
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
}
fn rc_with_wd(wd: &PathBuf) -> RunContext {
RunContext {
working_directory: Some(wd.to_string_lossy().into_owned()),
..Default::default()
}
}
#[test]
fn read_allows_working_dir_docs_skills() {
let wd = unique_tmp();
for sub in ["docs", "skills", "sub", "secrets"] {
std::fs::create_dir_all(wd.join(sub)).unwrap();
std::fs::write(wd.join(sub).join("f.txt"), "x").unwrap();
}
std::fs::write(wd.join("root.txt"), "x").unwrap();
let rc = rc_with_wd(&wd);
assert!(rc.is_read_allowed("root.txt"));
assert!(rc.is_read_allowed("docs/f.txt"));
assert!(rc.is_read_allowed("skills/f.txt"));
assert!(rc.is_read_allowed("sub/f.txt"));
// secrets/ is under the WD, so the fast-path allows it — the `secrets/` *deny rule*
// (consulted before this fast-path in the gate) is what actually blocks it.
assert!(rc.is_read_allowed("secrets/f.txt"));
std::fs::remove_dir_all(&wd).ok();
}
#[test]
fn read_denies_outside_working_dir() {
let wd = unique_tmp();
let outside = unique_tmp(); // sibling temp dir, not under wd
std::fs::write(outside.join("f.txt"), "x").unwrap();
let rc = rc_with_wd(&wd);
assert!(!rc.is_read_allowed(outside.join("f.txt").to_str().unwrap()));
std::fs::remove_dir_all(&wd).ok();
std::fs::remove_dir_all(&outside).ok();
}
#[test]
fn read_allows_write_paths_and_extra_reads() {
let wd = unique_tmp();
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 {
working_directory: Some(wd.to_string_lossy().into_owned()),
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(&wd).ok();
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 rc = RunContext {
working_directory: Some(wd.to_string_lossy().into_owned()),
allow_fs_writes: vec!["data".to_string()],
..Default::default()
};
// Writing into data/ is allowed...
assert!(rc.is_write_allowed("data/new.txt"));
// ...but data/../secrets/x escapes the grant and must NOT be allowed.
assert!(!rc.is_write_allowed("data/../secrets/x.txt"));
std::fs::remove_dir_all(&wd).ok();
}
}