- 1
use std::path::{Path, PathBuf}; - 2
- 3
use serde_json::Value; - 4
- 5
use crate::Mode; - 6
use crate::rules::{Rule, RuleDecision}; - 7
- 8
#[derive(Debug, Clone, PartialEq)] - 9
pub enum Decision { - 10
Allow, - 11
Ask { reason: String, source: AskSource }, - 12
Deny { reason: String }, - 13
} - 14
- 15
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 16
pub enum AskSource { - 17
Rule, - 18
Scope, - 19
ModeDefault, - 20
CircuitBreaker, - 21
} - 22
- 23
#[derive(Debug, Clone, Default)] - 24
pub struct PermissionEngine { - 25
rules: Vec<Rule>, - 26
write_scope: Option<Vec<PathBuf>>, - 27
/// Host-imposed execution vocabulary. Checked before user allow rules. - 28
allowed_tools: Option<Vec<String>>, - 29
/// Tools that only display something to the user (`Tool::presents_cards`). - 30
/// Supplied by the host from the tools' own declarations, never inferred - 31
/// from a name here. - 32
presenting: Vec<String>, - 33
} - 34
- 35
/// Tools that read and change nothing. `commitments` reads the Agent's own - 36
/// portfolio, like `session_search` reads its own history: a model that must - 37
/// ask a person before it may look at its own obligations cannot answer - 38
/// "what are you working on?" on an unattended surface at all. - 39
const READ_TOOLS: [&str; 9] = [ - 40
"read", - 41
"doc_read", - 42
"glob", - 43
"grep", - 44
"ls", - 45
"search", - 46
"session_search", - 47
"skill", - 48
"commitments", - 49
]; - 50
const PATH_SCOPED_READ_TOOLS: [&str; 5] = ["read", "doc_read", "glob", "grep", "ls"]; - 51
const WRITE_TOOLS: [&str; 3] = ["write", "edit", "office_apply"]; - 52
/// Learning-loop journaling into vak's own per-workspace store - 53
/// (docs/design/26-learning.md): sanctioned under workspace-write, still - 54
/// denied by read-only's default arm below. - 55
const LEARNING_TOOLS: [&str; 2] = ["remember", "propose_skill"]; - 56
/// Tools whose reach exceeds the workspace (docs/design/29-personal-os.md - 57
/// P4). Outside FullAccess they gate on approval rather than following the - 58
/// surrounding mode's arm: read-only would otherwise deny them outright, - 59
/// and the intent is "a human may still say yes", not "never". - 60
/// - 61
/// This lives HERE, in the mode arms, rather than being injected as a - 62
/// synthetic `?webfetch` rule by the engine's caller. An injected rule is - 63
/// indistinguishable from one an operator typed, and `auto_approve` - 64
/// deliberately refuses to resolve rule-sourced asks on the model's behalf - 65
/// — so the injection silently made `approval_mode = "auto-approve"` a - 66
/// no-op for exactly these two tools while working for every other one. - 67
/// A mode default must be sourced as a mode default. - 68
const NETWORK_TOOLS: [&str; 2] = ["webfetch", "browse"]; - 69
- 70
impl PermissionEngine { - 71
pub fn new(rules: Vec<Rule>) -> Self { - 72
PermissionEngine { - 73
rules, - 74
write_scope: None, - 75
allowed_tools: None, - 76
presenting: Vec::new(), - 77
} - 78
} - 79
- 80
pub fn from_rule_strings(specs: &[String]) -> Result<Self, crate::rules::RuleError> { - 81
let mut rules = Vec::with_capacity(specs.len()); - 82
for s in specs { - 83
rules.push(Rule::parse(s)?); - 84
} - 85
Ok(PermissionEngine { - 86
rules, - 87
write_scope: None, - 88
allowed_tools: None, - 89
presenting: Vec::new(), - 90
}) - 91
} - 92
- 93
/// Declare the tools that present cards. They show the user something - 94
/// Vak already holds and reach nothing outside the conversation, so every - 95
/// mode lets them through (read-only included) with no approval. An - 96
/// operator's explicit Deny or Ask rule still wins. - 97
pub fn with_presenting_tools(mut self, names: impl IntoIterator<Item = String>) -> Self { - 98
self.presenting = names.into_iter().collect(); - 99
self - 100
} - 101
- 102
pub fn rules(&self) -> &[Rule] { - 103
&self.rules - 104
} - 105
- 106
/// True when some rule targets `tool` with an argument pattern. - 107
/// - 108
/// Reachability preflight (`vak_core::reach`) probes a capability - 109
/// before its arguments exist, so a patterned rule cannot be evaluated - 110
/// yet. Its existence means the probe's answer is provisional, and the - 111
/// preflight degrades to "gated" rather than reporting a capability as - 112
/// unreachable on evidence it does not have. Hiding a capability that - 113
/// would in fact have worked is the one outcome worse than advertising - 114
/// one that gates. - 115
pub fn has_patterned_rule(&self, tool: &str) -> bool { - 116
self.rules - 117
.iter() - 118
.any(|rule| rule.targets(tool) && rule.arg_glob.is_some()) - 119
} - 120
- 121
/// Restrict direct file mutations to paths explicitly supplied by the - 122
/// hosting surface. This is an execution contract, not a model prompt; - 123
/// it is evaluated before rules and permission mode. - 124
pub fn restrict_write_paths(mut self, cwd: &Path, paths: &[PathBuf]) -> Self { - 125
self.write_scope = Some( - 126
paths - 127
.iter() - 128
.map(|path| normalize_scope_path(path, cwd)) - 129
.collect(), - 130
); - 131
self - 132
} - 133
- 134
/// Narrow an isolated run to these tool names. No configured allow rule - 135
/// or broad permission mode can re-enable a tool outside this set. - 136
pub fn restrict_tools(mut self, names: &[&str]) -> Self { - 137
self.allowed_tools = Some(names.iter().map(|name| name.to_ascii_lowercase()).collect()); - 138
self - 139
} - 140
- 141
/// Restrictive rules first — Deny beats Ask no matter what order they - 142
/// were registered in — then allow-coverage, then the mode default. - 143
/// - 144
/// `Allow` is universally quantified: the allow rules must cover EVERY - 145
/// effect the invocation can have. One matching segment of a compound - 146
/// shell command never authorizes its neighbours, so `+Bash(git *)` - 147
/// does not allow `git status; rm -rf /`. - 148
pub fn evaluate( - 149
&self, - 150
tool: &str, - 151
args: &Value, - 152
mode: Mode, - 153
cwd: &std::path::Path, - 154
) -> Decision { - 155
if self - 156
.allowed_tools - 157
.as_ref() - 158
.is_some_and(|allowed| !allowed.iter().any(|name| name.eq_ignore_ascii_case(tool))) - 159
{ - 160
return Decision::Deny { - 161
reason: format!("'{tool}' is outside this run's tool scope"), - 162
}; - 163
} - 164
if WRITE_TOOLS.contains(&tool) - 165
&& let Some(scope) = &self.write_scope - 166
{ - 167
let Some(path) = args.get("path").and_then(|value| value.as_str()) else { - 168
return Decision::Deny { - 169
reason: "write scope requires a path argument".into(), - 170
}; - 171
}; - 172
let candidate = normalize_scope_path(Path::new(path), cwd); - 173
if !scope.contains(&candidate) { - 174
return Decision::Deny { - 175
reason: format!("'{path}' is outside this run's declared write scope"), - 176
}; - 177
} - 178
} - 179
if self - 180
.rules - 181
.iter() - 182
.any(|rule| rule.decision == RuleDecision::Deny && rule.matches(tool, args)) - 183
{ - 184
return Decision::Deny { - 185
reason: format!("denied by rule: {}", describe(tool, args)), - 186
}; - 187
} - 188
if self - 189
.rules - 190
.iter() - 191
.any(|rule| rule.decision == RuleDecision::Ask && rule.matches(tool, args)) - 192
{ - 193
return Decision::Ask { - 194
reason: format!("rule requires approval: {}", describe(tool, args)), - 195
source: AskSource::Rule, - 196
}; - 197
} - 198
if crate::rules::allow_covers(&self.rules, tool, args) - 199
|| self.presenting.iter().any(|name| name == tool) - 200
{ - 201
return Decision::Allow; - 202
} - 203
- 204
match mode { - 205
Mode::FullAccess => Decision::Allow, - 206
Mode::ReadOnly => { - 207
if READ_TOOLS.contains(&tool) { - 208
scoped_read_decision(tool, args, cwd) - 209
} else if tool == "mcp" - 210
&& args.get("action").and_then(|a| a.as_str()) == Some("list") - 211
{ - 212
Decision::Allow - 213
} else if NETWORK_TOOLS.contains(&tool) { - 214
Decision::Ask { - 215
reason: format!("'{tool}' reaches outside the workspace"), - 216
source: AskSource::ModeDefault, - 217
} - 218
} else { - 219
Decision::Deny { - 220
reason: format!( - 221
"read-only mode denies '{tool}'; switch modes or add a rule" - 222
), - 223
} - 224
} - 225
} - 226
Mode::WorkspaceWrite => { - 227
if READ_TOOLS.contains(&tool) { - 228
return scoped_read_decision(tool, args, cwd); - 229
} - 230
if tool == "mcp" && args.get("action").and_then(|a| a.as_str()) == Some("list") { - 231
return Decision::Allow; - 232
} - 233
if WRITE_TOOLS.contains(&tool) { - 234
return match args.get("path").and_then(|p| p.as_str()) { - 235
Some(path) => { - 236
if path_in_workspace(std::path::Path::new(path), cwd) { - 237
Decision::Allow - 238
} else { - 239
Decision::Ask { - 240
reason: format!("'{path}' is outside the workspace"), - 241
source: AskSource::Scope, - 242
} - 243
} - 244
} - 245
None => Decision::Ask { - 246
reason: "missing path argument".into(), - 247
source: AskSource::ModeDefault, - 248
}, - 249
}; - 250
} - 251
if LEARNING_TOOLS.contains(&tool) { - 252
return Decision::Allow; - 253
} - 254
if tool == "bash" { - 255
return Decision::Ask { - 256
reason: format!("shell command needs approval: {}", describe(tool, args)), - 257
source: AskSource::ModeDefault, - 258
}; - 259
} - 260
Decision::Ask { - 261
reason: format!("'{}' needs approval: {}", tool, describe(tool, args)), - 262
source: AskSource::ModeDefault, - 263
} - 264
} - 265
} - 266
} - 267
} - 268
- 269
fn scoped_read_decision(tool: &str, args: &Value, cwd: &std::path::Path) -> Decision { - 270
if !PATH_SCOPED_READ_TOOLS.contains(&tool) { - 271
return Decision::Allow; - 272
} - 273
let path = args.get("path").and_then(|p| p.as_str()); - 274
if tool == "read" && path.is_none() { - 275
return Decision::Deny { - 276
reason: "read requires a workspace-scoped path".into(), - 277
}; - 278
} - 279
match path { - 280
Some(path) if !path_in_workspace(std::path::Path::new(path), cwd) => Decision::Deny { - 281
reason: format!( - 282
"'{path}' is outside the workspace; use full-access or an explicit scoped rule" - 283
), - 284
}, - 285
_ => Decision::Allow, - 286
} - 287
} - 288
- 289
fn describe(tool: &str, args: &Value) -> String { - 290
match tool { - 291
"bash" => args - 292
.get("command") - 293
.and_then(|c| c.as_str()) - 294
.map(|c| { - 295
let preview: String = c.chars().take(80).collect(); - 296
format!("bash `{preview}`") - 297
}) - 298
.unwrap_or_else(|| "bash".into()), - 299
"write" | "edit" | "read" | "doc_read" | "office_apply" => args - 300
.get("path") - 301
.and_then(|p| p.as_str()) - 302
.map(|p| format!("{tool} {p}")) - 303
.unwrap_or_else(|| tool.to_string()), - 304
"mcp" => match ( - 305
args.get("action").and_then(|a| a.as_str()), - 306
args.get("server").and_then(|s| s.as_str()), - 307
args.get("tool").and_then(|t| t.as_str()), - 308
) { - 309
(Some("call"), Some(server), Some(mcp_tool)) => { - 310
format!("mcp call {server}/{mcp_tool}") - 311
} - 312
(Some("call"), Some(server), None) => format!("mcp call {server}/<missing tool>"), - 313
(Some("list"), _, _) => "mcp list".to_string(), - 314
_ => "mcp".to_string(), - 315
}, - 316
"task" => { - 317
let label = args - 318
.get("label") - 319
.and_then(|l| l.as_str()) - 320
.map(String::from) - 321
.unwrap_or_else(|| { - 322
let prompt = args.get("prompt").and_then(|p| p.as_str()).unwrap_or(""); - 323
prompt.chars().take(60).collect() - 324
}); - 325
format!("task '{label}'") - 326
} - 327
other => other.to_string(), - 328
} - 329
} - 330
- 331
pub fn path_in_workspace(path: &std::path::Path, cwd: &std::path::Path) -> bool { - 332
let Ok(cwd_abs) = cwd.canonicalize() else { - 333
return false; - 334
}; - 335
let candidate = if path.is_absolute() { - 336
path.to_path_buf() - 337
} else { - 338
cwd_abs.join(path) - 339
}; - 340
match resolve_through_existing(&candidate) { - 341
Some(resolved) => resolved.starts_with(&cwd_abs), - 342
None => false, - 343
} - 344
} - 345
- 346
/// Canonicalizes the longest existing prefix of `path`, then re-applies the - 347
/// components that do not exist yet. - 348
/// - 349
/// `Path::canonicalize` fails outright when the leaf has not been created — - 350
/// the normal case for `write`. Resolving purely lexically instead (what this - 351
/// used to do) cannot see a symlinked ancestor, so a workspace containing - 352
/// `link -> /etc` accepted a write to `link/passwd` as in-workspace. - 353
/// - 354
/// Returns `None` when the path cannot be resolved at all; callers treat that - 355
/// as "outside", so this fails closed. - 356
fn resolve_through_existing(path: &Path) -> Option<PathBuf> { - 357
let mut existing = path.to_path_buf(); - 358
let mut pending: Vec<std::ffi::OsString> = Vec::new(); - 359
loop { - 360
if let Ok(base) = existing.canonicalize() { - 361
let mut out = base; - 362
for part in pending.iter().rev() { - 363
if part == ".." { - 364
if !out.pop() { - 365
return None; - 366
} - 367
} else if part != "." { - 368
out.push(part); - 369
} - 370
} - 371
return Some(out); - 372
} - 373
pending.push(existing.file_name()?.to_os_string()); - 374
if !existing.pop() { - 375
return None; - 376
} - 377
} - 378
} - 379
- 380
fn normalize_scope_path(path: &Path, cwd: &Path) -> PathBuf { - 381
let base = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); - 382
let candidate = if path.is_absolute() { - 383
path.to_path_buf() - 384
} else { - 385
base.join(path) - 386
}; - 387
resolve_through_existing(&candidate).unwrap_or(candidate) - 388
} - 389
- 390
#[cfg(test)] - 391
mod tests { - 392
use super::*; - 393
- 394
#[test] - 395
fn test_describe_formats_task() { - 396
let task_args = serde_json::json!({ - 397
"label": "build project" - 398
}); - 399
assert_eq!(describe("task", &task_args), "task 'build project'"); - 400
} - 401
} - 402
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.