- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
use serde_json::json; - 4
use vak_permission::engine::Decision; - 5
use vak_permission::{Mode, PermissionEngine, Rule}; - 6
- 7
fn bash(cmd: &str) -> serde_json::Value { - 8
json!({"command": cmd}) - 9
} - 10
- 11
#[test] - 12
fn rule_parsing_forms() { - 13
let r = Rule::parse("Bash(git *)").unwrap(); - 14
assert_eq!(r.tool, "Bash"); - 15
assert_eq!(r.decision, vak_permission::RuleDecision::Allow); - 16
assert!(r.arg_glob.is_some()); - 17
- 18
let r = Rule::parse("-Bash(rm *)").unwrap(); - 19
assert_eq!(r.decision, vak_permission::RuleDecision::Deny); - 20
- 21
let r = Rule::parse("?Edit(src/**)").unwrap(); - 22
assert_eq!(r.decision, vak_permission::RuleDecision::Ask); - 23
- 24
let r = Rule::parse("read").unwrap(); - 25
assert_eq!(r.tool, "read"); - 26
assert!(r.arg_glob.is_none()); - 27
- 28
assert!(Rule::parse("Bash(git").is_err()); - 29
assert!(Rule::parse("(x)").is_err()); - 30
assert!(Rule::parse("Bad Tool(x)").is_err()); - 31
} - 32
- 33
#[test] - 34
fn allow_rule_beats_mode_default() { - 35
let eng = PermissionEngine::from_rule_strings(&["Bash(git *)".to_string()]).unwrap(); - 36
let d = eng.evaluate( - 37
"bash", - 38
&bash("git push"), - 39
Mode::WorkspaceWrite, - 40
std::path::Path::new("/tmp"), - 41
); - 42
assert_eq!(d, Decision::Allow); - 43
} - 44
- 45
#[test] - 46
fn deny_rule_wins_over_allow_mode() { - 47
let eng = - 48
PermissionEngine::from_rule_strings(&["-Bash(rm *)".to_string(), "Bash(*)".to_string()]) - 49
.unwrap(); - 50
let d = eng.evaluate( - 51
"bash", - 52
&bash("rm -rf /tmp/x"), - 53
Mode::FullAccess, - 54
std::path::Path::new("/tmp"), - 55
); - 56
assert!(matches!(d, Decision::Deny { .. })); - 57
- 58
let d = eng.evaluate( - 59
"bash", - 60
&bash("ls -la"), - 61
Mode::FullAccess, - 62
std::path::Path::new("/tmp"), - 63
); - 64
assert_eq!(d, Decision::Allow); - 65
} - 66
- 67
#[test] - 68
fn bash_subcommand_matching() { - 69
let eng = PermissionEngine::from_rule_strings(&["Bash(git push *)".to_string()]).unwrap(); - 70
let d = eng.evaluate( - 71
"bash", - 72
&bash("npm test && git push origin main"), - 73
Mode::WorkspaceWrite, - 74
std::path::Path::new("/tmp"), - 75
); - 76
assert!( - 77
matches!(d, Decision::Ask { .. }), - 78
"`npm test` is not covered by `Bash(git push *)`; one covered segment \ - 79
must not authorize its neighbours" - 80
); - 81
- 82
let d = eng.evaluate( - 83
"bash", - 84
&bash("git push origin main && git push --tags"), - 85
Mode::WorkspaceWrite, - 86
std::path::Path::new("/tmp"), - 87
); - 88
assert_eq!(d, Decision::Allow, "every segment is covered"); - 89
- 90
let d = eng.evaluate( - 91
"bash", - 92
&bash("FOO=bar git push origin main"), - 93
Mode::WorkspaceWrite, - 94
std::path::Path::new("/tmp"), - 95
); - 96
assert_eq!(d, Decision::Allow, "leading env assignments are stripped"); - 97
} - 98
- 99
#[test] - 100
fn read_only_mode_allows_reads_denies_writes() { - 101
let eng = PermissionEngine::default(); - 102
let d = eng.evaluate( - 103
"read", - 104
&json!({"path": "a.txt"}), - 105
Mode::ReadOnly, - 106
std::path::Path::new("/tmp"), - 107
); - 108
assert_eq!(d, Decision::Allow); - 109
let d = eng.evaluate( - 110
"glob", - 111
&json!({"pattern": "**"}), - 112
Mode::ReadOnly, - 113
std::path::Path::new("/tmp"), - 114
); - 115
assert_eq!(d, Decision::Allow); - 116
let d = eng.evaluate( - 117
"write", - 118
&json!({"path": "a.txt"}), - 119
Mode::ReadOnly, - 120
std::path::Path::new("/tmp"), - 121
); - 122
assert!(matches!(d, Decision::Deny { .. })); - 123
let d = eng.evaluate( - 124
"bash", - 125
&bash("echo hi"), - 126
Mode::ReadOnly, - 127
std::path::Path::new("/tmp"), - 128
); - 129
assert!(matches!(d, Decision::Deny { .. })); - 130
} - 131
- 132
#[test] - 133
fn restricted_modes_deny_reads_outside_workspace() { - 134
let cwd = std::env::temp_dir().join("vak-perm-read-scope"); - 135
std::fs::create_dir_all(&cwd).unwrap(); - 136
let outside = cwd.parent().unwrap().join("vak-perm-secret.txt"); - 137
std::fs::write(&outside, "secret").unwrap(); - 138
let eng = PermissionEngine::default(); - 139
- 140
for mode in [Mode::ReadOnly, Mode::WorkspaceWrite] { - 141
for tool in ["read", "glob", "grep"] { - 142
let d = eng.evaluate(tool, &json!({"path": outside}), mode, &cwd); - 143
assert!( - 144
matches!(d, Decision::Deny { .. }), - 145
"{tool} escaped in {mode:?}" - 146
); - 147
} - 148
} - 149
- 150
let d = eng.evaluate("read", &json!({"path": outside}), Mode::FullAccess, &cwd); - 151
assert_eq!(d, Decision::Allow); - 152
- 153
let _ = std::fs::remove_dir_all(&cwd); - 154
let _ = std::fs::remove_file(&outside); - 155
} - 156
- 157
#[test] - 158
fn restricted_read_scope_resolves_symlinks() { - 159
#[cfg(unix)] - 160
{ - 161
let cwd = std::env::temp_dir().join("vak-perm-read-symlink"); - 162
let outside = std::env::temp_dir().join("vak-perm-read-symlink-secret.txt"); - 163
// Fixed paths: a previously failed run leaves both behind, and - 164
// `symlink` on an existing name is an error, so start from clean. - 165
let _ = std::fs::remove_dir_all(&cwd); - 166
let _ = std::fs::remove_file(&outside); - 167
std::fs::create_dir_all(&cwd).unwrap(); - 168
std::fs::write(&outside, "secret").unwrap(); - 169
std::os::unix::fs::symlink(&outside, cwd.join("looks-safe")).unwrap(); - 170
- 171
let d = PermissionEngine::default().evaluate( - 172
"read", - 173
&json!({"path": "looks-safe"}), - 174
Mode::WorkspaceWrite, - 175
&cwd, - 176
); - 177
assert!(matches!(d, Decision::Deny { .. })); - 178
- 179
let _ = std::fs::remove_dir_all(&cwd); - 180
let _ = std::fs::remove_file(&outside); - 181
} - 182
} - 183
- 184
#[test] - 185
fn workspace_write_scopes_file_tools() { - 186
let cwd = std::env::temp_dir().join("vak-perm-test"); - 187
std::fs::create_dir_all(&cwd).unwrap(); - 188
let eng = PermissionEngine::default(); - 189
- 190
let inside = cwd.join("sub/inner.txt"); - 191
std::fs::create_dir_all(inside.parent().unwrap()).unwrap(); - 192
std::fs::write(&inside, "x").unwrap(); - 193
let d = eng.evaluate( - 194
"write", - 195
&json!({"path": inside}), - 196
Mode::WorkspaceWrite, - 197
&cwd, - 198
); - 199
assert_eq!(d, Decision::Allow, "absolute path inside workspace allowed"); - 200
- 201
let outside = std::env::temp_dir().join("vak-perm-outside.txt"); - 202
std::fs::write(&outside, "x").unwrap(); - 203
let d = eng.evaluate( - 204
"edit", - 205
&json!({"path": outside}), - 206
Mode::WorkspaceWrite, - 207
&cwd, - 208
); - 209
assert!(matches!(d, Decision::Ask { .. }), "outside workspace asks"); - 210
- 211
let d = eng.evaluate( - 212
"write", - 213
&json!({"path": "relative/new.txt"}), - 214
Mode::WorkspaceWrite, - 215
&cwd, - 216
); - 217
assert_eq!( - 218
d, - 219
Decision::Allow, - 220
"relative paths resolve into the workspace" - 221
); - 222
- 223
let _ = std::fs::remove_dir_all(&cwd); - 224
let _ = std::fs::remove_file(&outside); - 225
} - 226
- 227
#[test] - 228
fn bash_asks_in_workspace_write_by_default() { - 229
let eng = PermissionEngine::default(); - 230
let d = eng.evaluate( - 231
"bash", - 232
&bash("cargo test"), - 233
Mode::WorkspaceWrite, - 234
std::path::Path::new("/tmp"), - 235
); - 236
match d { - 237
Decision::Ask { reason, .. } => assert!(reason.contains("cargo test")), - 238
other => panic!("expected ask, got {other:?}"), - 239
} - 240
} - 241
- 242
#[test] - 243
fn first_matching_rule_wins_in_order() { - 244
let eng = PermissionEngine::from_rule_strings(&[ - 245
"?Bash(npm *)".to_string(), - 246
"Bash(npm *)".to_string(), - 247
]) - 248
.unwrap(); - 249
let d = eng.evaluate( - 250
"bash", - 251
&bash("npm run build"), - 252
Mode::FullAccess, - 253
std::path::Path::new("/tmp"), - 254
); - 255
assert!( - 256
matches!(d, Decision::Ask { .. }), - 257
"earlier ? rule wins over later allow" - 258
); - 259
} - 260
- 261
#[test] - 262
fn deny_beats_allow_regardless_of_registration_order() { - 263
// Deny registered AFTER the allow: severity aggregation must still - 264
// pick Deny. Under first-match-wins this silently allowed. - 265
let eng = PermissionEngine::from_rule_strings(&[ - 266
"Bash(git *)".to_string(), - 267
"-Bash(git push *)".to_string(), - 268
]) - 269
.unwrap(); - 270
let d = eng.evaluate( - 271
"bash", - 272
&bash("git push origin main"), - 273
Mode::WorkspaceWrite, - 274
std::path::Path::new("/tmp"), - 275
); - 276
assert!(matches!(d, Decision::Deny { .. })); - 277
- 278
let d = eng.evaluate( - 279
"bash", - 280
&bash("git status"), - 281
Mode::WorkspaceWrite, - 282
std::path::Path::new("/tmp"), - 283
); - 284
assert_eq!(d, Decision::Allow); - 285
} - 286
- 287
#[test] - 288
fn substitution_and_newline_commands_are_opaque_to_allow_patterns() { - 289
let eng = PermissionEngine::from_rule_strings(&[ - 290
"Bash(git *)".to_string(), - 291
"-Bash(rm *)".to_string(), - 292
]) - 293
.unwrap(); - 294
- 295
for cmd in [ - 296
"git status\nrm -rf ~", - 297
"git log --format=$(rm -rf ~)", - 298
"git log `rm -rf ~`", - 299
] { - 300
let d = eng.evaluate( - 301
"bash", - 302
&bash(cmd), - 303
Mode::WorkspaceWrite, - 304
std::path::Path::new("/tmp"), - 305
); - 306
assert!( - 307
!matches!(d, Decision::Allow), - 308
"'{cmd}' must not match a patterned allow rule" - 309
); - 310
} - 311
} - 312
- 313
#[test] - 314
fn blanket_rules_still_cover_opaque_commands() { - 315
let eng = PermissionEngine::from_rule_strings(&["Bash(*)".to_string()]).unwrap(); - 316
let d = eng.evaluate( - 317
"bash", - 318
&bash("git status\necho hi"), - 319
Mode::WorkspaceWrite, - 320
std::path::Path::new("/tmp"), - 321
); - 322
assert_eq!(d, Decision::Allow); - 323
} - 324
- 325
#[test] - 326
fn mcp_calls_match_server_tool_candidates() { - 327
let eng = PermissionEngine::from_rule_strings(&[ - 328
"-Mcp(evil-server/*)".to_string(), - 329
"Mcp(docs/*)".to_string(), - 330
]) - 331
.unwrap(); - 332
let args = json!({"action": "call", "server": "docs", "tool": "search", "arguments": {}}); - 333
let d = eng.evaluate( - 334
"mcp", - 335
&args, - 336
Mode::WorkspaceWrite, - 337
std::path::Path::new("/tmp"), - 338
); - 339
assert_eq!(d, Decision::Allow); - 340
- 341
let args = json!({"action": "call", "server": "evil-server", "tool": "wipe"}); - 342
let d = eng.evaluate( - 343
"mcp", - 344
&args, - 345
Mode::WorkspaceWrite, - 346
std::path::Path::new("/tmp"), - 347
); - 348
assert!(matches!(d, Decision::Deny { .. })); - 349
} - 350
- 351
#[test] - 352
fn declared_write_scope_denies_other_direct_file_mutations_before_rules() { - 353
let cwd = std::env::temp_dir().join("vak-permission-write-scope-test"); - 354
let engine = PermissionEngine::default() - 355
.restrict_write_paths(&cwd, &[std::path::PathBuf::from("app.py")]); - 356
- 357
let allowed = engine.evaluate( - 358
"write", - 359
&json!({"path": "app.py", "content": "ok"}), - 360
Mode::FullAccess, - 361
&cwd, - 362
); - 363
assert_eq!(allowed, Decision::Allow); - 364
- 365
let denied = engine.evaluate( - 366
"edit", - 367
&json!({"path": "test_app.py", "old_text": "x", "new_text": "y"}), - 368
Mode::FullAccess, - 369
&cwd, - 370
); - 371
assert!(matches!(denied, Decision::Deny { .. })); - 372
} - 373
- 374
#[test] - 375
fn task_tool_scope_beats_full_access_and_explicit_allow() { - 376
let engine = PermissionEngine::from_rule_strings(&["+remember".into(), "+write".into()]) - 377
.unwrap() - 378
.restrict_tools(&["read", "write", "edit"]); - 379
let cwd = std::env::temp_dir(); - 380
assert!(matches!( - 381
engine.evaluate( - 382
"remember", - 383
&json!({"text": "secret"}), - 384
Mode::FullAccess, - 385
&cwd - 386
), - 387
Decision::Deny { .. } - 388
)); - 389
assert_eq!( - 390
engine.evaluate( - 391
"write", - 392
&json!({"path": "draft.txt"}), - 393
Mode::FullAccess, - 394
&cwd - 395
), - 396
Decision::Allow - 397
); - 398
} - 399
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.