- 1
//! Workspace trust: one marker store, read the same way everywhere. - 2
//! - 3
//! A project's `.vak/config.toml` and secret scope can grant execution - 4
//! power (permission mode, allow rules, hooks, MCP servers, base-URL - 5
//! redirection), so they stay demoted until the operator says otherwise - 6
//! (`docs/design/05-config.md`). The decision is per canonical directory - 7
//! and remembered under `<data_home>/trusted/`. - 8
//! - 9
//! This lived in the CLI binary, where the server and the onboarding - 10
//! projection could not reach it — so "is this workspace trusted?" had - 11
//! one implementation and several guesses. One authority now. - 12
- 13
use std::path::{Path, PathBuf}; - 14
- 15
fn fnv1a(bytes: &[u8]) -> u64 { - 16
let mut h: u64 = 0xcbf2_9ce4_8422_2325; - 17
for b in bytes { - 18
h ^= u64::from(*b); - 19
h = h.wrapping_mul(0x0000_0100_0000_01b3); - 20
} - 21
h - 22
} - 23
- 24
/// Where the trust decision for `cwd` is recorded. - 25
/// - 26
/// The path is canonicalized first. Without that, one directory has as many - 27
/// trust records as it has spellings — `/tmp/x` and `/private/tmp/x` are the - 28
/// same directory on macOS, a relative path and its absolute form are the - 29
/// same directory everywhere, and a decision recorded through one is - 30
/// invisible through the other. Callers do not agree on a spelling - 31
/// (`CorePool` canonicalizes its keys; the CLI passes the cwd as given), and - 32
/// "is this workspace trusted?" having two answers is the exact failure this - 33
/// module exists to end. - 34
/// - 35
/// A path that cannot be canonicalized (it does not exist yet) falls back to - 36
/// its literal form rather than failing: recording a decision about a - 37
/// directory that is about to be created is legitimate. - 38
pub fn marker_path(cwd: &Path) -> PathBuf { - 39
marker_for(&cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf())) - 40
} - 41
- 42
fn marker_for(path: &Path) -> PathBuf { - 43
vak_config::paths::data_home() - 44
.join("trusted") - 45
.join(format!("{:016x}", fnv1a(path.to_string_lossy().as_bytes()))) - 46
} - 47
- 48
/// True when this workspace has a recorded trust decision. - 49
/// - 50
/// Checks the literal spelling too, so a marker written before paths were - 51
/// canonicalized still counts. Decisions an operator has already made must - 52
/// not be silently forgotten by a change to how they are addressed. - 53
pub fn is_trusted(cwd: &Path) -> bool { - 54
marker_path(cwd).is_file() || marker_for(cwd).is_file() - 55
} - 56
- 57
/// Record a trust decision for `cwd`, the same way the CLI's own interactive - 58
/// "trust this workspace?" prompt does — a file at [`marker_path`], so every - 59
/// later [`is_trusted`] call (including `CorePool`'s) sees it immediately. - 60
/// - 61
/// A user-created Agent's isolated workspace (`.vak/agents/<id>/workspace`) - 62
/// is never visited or prompted about directly, so without this it can never - 63
/// pass `is_trusted` and its own `permission_mode`, `hooks`, `mcp.servers`, - 64
/// and other privileged config are silently stripped forever (see - 65
/// `vak_config`'s `PRIVILEGED_KEYS_NOTICE`) — an Agent whose settings a user - 66
/// configures through a trusted admin session but that silently never apply. - 67
/// Callers must only invoke this when the *creating* context is itself - 68
/// already trusted; it is not a substitute for that decision, only a way to - 69
/// carry it forward onto a directory the decision already covers in spirit. - 70
pub fn mark_trusted(cwd: &Path) -> std::io::Result<()> { - 71
let marker = marker_path(cwd); - 72
if let Some(parent) = marker.parent() { - 73
std::fs::create_dir_all(parent)?; - 74
} - 75
std::fs::write(&marker, cwd.to_string_lossy().as_bytes()) - 76
} - 77
- 78
/// True when the workspace asks for nothing privileged, so opening it - 79
/// needs no decision at all. Keeping this distinct from `is_trusted` - 80
/// is what lets onboarding stay silent for an ordinary directory and - 81
/// speak up only for one that actually requests power. - 82
pub fn requests_privilege(cwd: &Path) -> bool { - 83
vak_config::project_path(cwd).is_file() - 84
|| vak_config::credentials::scope_has_any(&cwd.join(".env")) - 85
} - 86
- 87
/// Which privileged sections a workspace's project layer actually asks - 88
/// for, read as **text**, never loaded. - 89
/// - 90
/// The trust review has to tell an operator what they would be agreeing - 91
/// to, and it must do that without activating any of it — parsing the - 92
/// config through the normal loader to describe it would be granting the - 93
/// thing being asked about (doc 46, Step 2). - 94
pub fn requested_privileges(cwd: &Path) -> Vec<&'static str> { - 95
let mut found = Vec::new(); - 96
if vak_config::credentials::scope_has_any(&cwd.join(".env")) { - 97
found.push("secrets in this project's secret scope"); - 98
} - 99
let Ok(text) = std::fs::read_to_string(vak_config::project_path(cwd)) else { - 100
return found; - 101
}; - 102
// Section headers and top-level keys only. A substring scan is enough - 103
// to answer "does this file ask for X?", and cannot execute anything. - 104
for (needle, label) in [ - 105
("[[hooks]]", "hooks that run commands"), - 106
("[hooks]", "hooks that run commands"), - 107
// No closing bracket: a server is declared as - 108
// `[mcp.servers.<name>]`, so matching the full header would miss - 109
// every real one. - 110
("[mcp.servers", "external tool servers"), - 111
("permission_mode", "a permission mode"), - 112
("anthropic_base_url", "a redirected provider endpoint"), - 113
("[sandbox]", "sandbox settings"), - 114
("[gateway]", "gateway settings"), - 115
// Network exposure: which interface answers, which Host headers are - 116
// accepted, and whether a remote caller reaches a shell - 117
// (docs/design/48-web-client.md §4.2). - 118
("[server]", "network exposure settings"), - 119
("allow", "pre-granted allow rules"), - 120
("[prompt", "prompt layers"), - 121
] { - 122
if text.contains(needle) && !found.contains(&label) { - 123
found.push(label); - 124
} - 125
} - 126
found - 127
} - 128
- 129
/// Record the decision. Best-effort: an unwritable data home means the - 130
/// workspace is simply asked about again next time, never silently - 131
/// trusted. - 132
pub fn record(cwd: &Path) -> std::io::Result<()> { - 133
let marker = marker_path(cwd); - 134
if let Some(parent) = marker.parent() { - 135
std::fs::create_dir_all(parent)?; - 136
} - 137
std::fs::write(marker, cwd.to_string_lossy().as_bytes()) - 138
} - 139
- 140
#[cfg(test)] - 141
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 142
mod tests { - 143
use super::*; - 144
- 145
/// One directory must have one trust record, however it is spelled. - 146
/// `CorePool` canonicalizes its keys and the CLI passes the cwd as - 147
/// given, so before this the same workspace could be trusted through one - 148
/// path and untrusted through the other. - 149
#[test] - 150
fn a_decision_is_visible_through_every_spelling_of_the_same_directory() { - 151
vak_config::paths::isolate_home_for_tests(); - 152
let dir = tempfile::tempdir().unwrap(); - 153
let canonical = dir.path().canonicalize().unwrap(); - 154
assert!(!is_trusted(dir.path())); - 155
- 156
record(dir.path()).unwrap(); - 157
assert!(is_trusted(dir.path())); - 158
assert!(is_trusted(&canonical), "canonical form sees it too"); - 159
- 160
// And a relative spelling of the same place. - 161
let relative = dir.path().join("./"); - 162
assert!(is_trusted(&relative)); - 163
} - 164
- 165
/// A marker written before paths were canonicalized still counts — - 166
/// decisions an operator already made must not be forgotten by a change - 167
/// to how they are addressed. - 168
#[test] - 169
fn a_legacy_uncanonicalized_marker_is_still_honoured() { - 170
vak_config::paths::isolate_home_for_tests(); - 171
let dir = tempfile::tempdir().unwrap(); - 172
// `/var/...` on macOS canonicalizes to `/private/var/...`, so this - 173
// literal-form marker is at a different hash than the current one. - 174
let legacy = marker_for(dir.path()); - 175
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); - 176
std::fs::write(&legacy, dir.path().to_string_lossy().as_bytes()).unwrap(); - 177
assert!(is_trusted(dir.path())); - 178
} - 179
- 180
#[test] - 181
fn a_directory_with_no_privileged_files_asks_for_nothing() { - 182
let dir = tempfile::tempdir().unwrap(); - 183
assert!(!requests_privilege(dir.path())); - 184
} - 185
- 186
#[test] - 187
fn a_project_config_or_env_makes_the_workspace_privileged() { - 188
let dir = tempfile::tempdir().unwrap(); - 189
std::fs::create_dir_all(dir.path().join(".vak")).unwrap(); - 190
std::fs::write(dir.path().join(".vak/config.toml"), "").unwrap(); - 191
assert!(requests_privilege(dir.path())); - 192
- 193
let other = tempfile::tempdir().unwrap(); - 194
vak_config::upsert_env_file(&other.path().join(".env"), "K", "v").unwrap(); - 195
assert!(requests_privilege(other.path())); - 196
} - 197
- 198
#[test] - 199
fn a_review_names_what_a_project_asks_for_without_loading_it() { - 200
let dir = tempfile::tempdir().unwrap(); - 201
std::fs::create_dir_all(dir.path().join(".vak")).unwrap(); - 202
std::fs::write( - 203
dir.path().join(".vak/config.toml"), - 204
"permission_mode = \"full-access\"\n[mcp.servers.thing]\ncommand = \"sh\"\n", - 205
) - 206
.unwrap(); - 207
let asked = requested_privileges(dir.path()); - 208
assert!(asked.contains(&"a permission mode"), "{asked:?}"); - 209
assert!(asked.contains(&"external tool servers"), "{asked:?}"); - 210
} - 211
- 212
#[test] - 213
fn a_plain_directory_asks_for_nothing() { - 214
let dir = tempfile::tempdir().unwrap(); - 215
assert!(requested_privileges(dir.path()).is_empty()); - 216
} - 217
- 218
#[test] - 219
fn distinct_paths_get_distinct_markers() { - 220
assert_ne!( - 221
marker_path(Path::new("/a/one")), - 222
marker_path(Path::new("/a/two")) - 223
); - 224
} - 225
} - 226
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.