run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s
Nightly Build / build (push) Successful in 6m31s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user