run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s

This commit is contained in:
2026-07-21 10:47:12 +01:00
parent 1db34b22ec
commit c8e4cb4384
20 changed files with 395 additions and 217 deletions
+20 -71
View File
@@ -1,4 +1,3 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Result, bail};
@@ -21,9 +20,13 @@ pub struct RunContext {
/// `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.
/// 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 working_directory: Option<String>,
pub project_root: Option<String>,
}
impl RunContext {
@@ -51,24 +54,14 @@ impl RunContext {
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.
/// 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 = self.effective_working_dir();
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))
@@ -76,20 +69,20 @@ impl RunContext {
}
/// 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.
/// 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 = self.effective_working_dir();
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), // working directory itself
canonicalize_for_policy(".", &wd), // process working directory
canonicalize_for_policy("docs", &wd),
canonicalize_for_policy("skills", &wd),
];
@@ -116,7 +109,7 @@ pub enum RunContextDecision {
/// 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`, `working_directory`) is
/// (`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.
///
@@ -303,57 +296,14 @@ mod tests {
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()
@@ -365,7 +315,6 @@ mod tests {
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();
}
@@ -408,15 +357,15 @@ mod tests {
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 {
working_directory: Some(wd.to_string_lossy().into_owned()),
allow_fs_writes: vec!["data".to_string()],
allow_fs_writes: vec![data_dir],
..Default::default()
};
// Writing into data/ is allowed...
assert!(rc.is_write_allowed("data/new.txt"));
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("data/../secrets/x.txt"));
assert!(!rc.is_write_allowed(wd.join("data").join("..").join("secrets").join("x.txt").to_str().unwrap()));
std::fs::remove_dir_all(&wd).ok();
}