- 1
//! Single-turn capability admission — the one pipeline for all five kinds. - 2
//! - 3
//! What a turn may call is computed from the reconciled [`CapabilitySet`] in - 4
//! **one** function that runs every kind (tool, skill, MCP server, hook, - 5
//! command) through the same two policy stages: - 6
//! - 7
//! 1. **Channel visibility** — allow/deny from the chat's [`ChannelPolicy`]. - 8
//! 2. **Reach** — a capability the composed permission policy fully blocks, - 9
//! or one revoked since the last published epoch, is removed. - 10
//! - 11
//! That is the whole of admission, and it is policy. What the turn's reading - 12
//! *predicts* it will need is a separate, later question — which admitted - 13
//! tools are loaded with full schemas and which wait behind `find_tools` — - 14
//! answered by [`super::surface`]. Keeping the two apart is what makes - 15
//! presentation safe to get wrong: a misread can cost a `find_tools` call, - 16
//! never a capability (docs/design/41-capability-registry.md, "Turn - 17
//! capabilities"; docs/design/68-context-engine.md §5). - 18
- 19
use std::collections::{BTreeSet, HashMap}; - 20
- 21
use vak_config::ChannelPolicy; - 22
use vak_hooks::{HookDef, HookEvent, HookFailureMode}; - 23
use vak_session::types::CapabilityKind; - 24
- 25
use super::snapshot::{Capability, CapabilityId, CapabilitySet}; - 26
- 27
/// The authoritative admitted set for one turn, built atomically so every - 28
/// consumer — schemas, prompt, broker, hooks — reads the same answer. - 29
#[derive(Debug, Clone, Default)] - 30
pub struct TurnCapabilities { - 31
/// Built-in tool identities admitted for this turn. - 32
pub tool_names: BTreeSet<String>, - 33
/// Descriptor projection of everything admitted, for the prompt and the - 34
/// ledger. - 35
pub descriptors: Vec<vak_session::types::CapabilityDescriptor>, - 36
/// Bare MCP tool name → the admitted server that owns it. The `mcp` - 37
/// broker is the only way an MCP tool is called; this lets the loop - 38
/// repair a call a model addresses by the bare name. Names that collide - 39
/// across servers, or with a built-in, are left out as ambiguous. - 40
pub mcp_tool_index: HashMap<String, String>, - 41
/// Admitted MCP server names. - 42
pub mcp_server_names: Vec<String>, - 43
/// Hooks to run this turn. - 44
pub hooks: Vec<HookDef>, - 45
/// Admitted skills, digest-pinned for loading. - 46
pub frozen_skills: Vec<crate::skills::FrozenSkill>, - 47
/// Whether the managed `flow` capability was admitted. - 48
pub flow_admitted: bool, - 49
} - 50
- 51
/// All turn-scoped inputs the pipeline reads. - 52
pub struct TurnProbe<'a> { - 53
/// The reconciled, versioned capability set the turn binds to. - 54
pub capabilities: &'a CapabilitySet, - 55
/// Immediate revocations, applied even before the next published epoch. - 56
pub revoked_ids: BTreeSet<CapabilityId>, - 57
/// Channel allow/deny overlay from the chat surface. - 58
pub channel_policy: &'a ChannelPolicy, - 59
/// Per-turn reach standings (`Core::capability_standings`). - 60
pub reach_standings: &'a [crate::reach::Standing], - 61
/// Discovered MCP tool catalogues, keyed by server name. - 62
pub mcp_inventory: &'a [(String, Vec<vak_mcp::McpToolInfo>)], - 63
/// Built-in tool names, which a bare MCP tool name may never shadow. - 64
pub builtin_names: &'a BTreeSet<String>, - 65
} - 66
- 67
fn blocked_ids(standings: &[crate::reach::Standing]) -> BTreeSet<CapabilityId> { - 68
standings - 69
.iter() - 70
.filter(|standing| standing.reach.is_blocked()) - 71
.map(|standing| standing.id.clone()) - 72
.collect() - 73
} - 74
- 75
fn admitted(cap: &Capability, probe: &TurnProbe<'_>, blocked: &BTreeSet<CapabilityId>) -> bool { - 76
visible_on_channel(&cap.id, probe.channel_policy) - 77
&& !blocked.contains(&cap.id) - 78
&& !probe.revoked_ids.contains(&cap.id) - 79
} - 80
- 81
fn visible_on_channel(id: &CapabilityId, policy: &ChannelPolicy) -> bool { - 82
match id.kind { - 83
// MCP policies use qualified `server/tool` globs: a server is visible - 84
// unless `server/*` is denied, and when an allow list exists it must - 85
// name the server or one of its tools. - 86
CapabilityKind::McpServer => { - 87
let whole = format!("{}/*", id.name); - 88
let denied = policy - 89
.mcp_deny - 90
.iter() - 91
.any(|pattern| crate::Core::policy_matches(std::slice::from_ref(pattern), &whole)); - 92
let allowed = policy.mcp_allow.as_ref().is_none_or(|patterns| { - 93
patterns.is_empty() - 94
|| patterns.iter().any(|pattern| { - 95
crate::Core::policy_matches(std::slice::from_ref(pattern), &whole) - 96
|| pattern.starts_with(&format!("{}/", id.name)) - 97
}) - 98
}); - 99
!denied && allowed - 100
} - 101
CapabilityKind::Tool | CapabilityKind::Command => { - 102
crate::Core::allowed_by(&policy.tools_allow, &policy.tools_deny, &id.name) - 103
} - 104
CapabilityKind::Skill => { - 105
crate::Core::allowed_by(&policy.skills_allow, &policy.skills_deny, &id.name) - 106
} - 107
CapabilityKind::Hook => { - 108
crate::Core::allowed_by(&policy.hooks_allow, &policy.hooks_deny, &id.name) - 109
} - 110
} - 111
} - 112
- 113
/// Bare MCP tool name → owning server, for admitted servers only. Used both - 114
/// at turn admission and by the broker's live catalogue observer, so a tool - 115
/// discovered mid-turn is indexed by the same rule. - 116
pub(crate) fn mcp_tool_index( - 117
inventory: &[(String, Vec<vak_mcp::McpToolInfo>)], - 118
admitted_servers: &BTreeSet<String>, - 119
builtins: &BTreeSet<String>, - 120
) -> HashMap<String, String> { - 121
let mut index = HashMap::new(); - 122
let mut ambiguous = BTreeSet::new(); - 123
for (server, tools) in inventory { - 124
if !admitted_servers.contains(server) { - 125
continue; - 126
} - 127
for tool in tools { - 128
if builtins.contains(&tool.name) { - 129
continue; - 130
} - 131
if index.insert(tool.name.clone(), server.clone()).is_some() { - 132
ambiguous.insert(tool.name.clone()); - 133
} - 134
} - 135
} - 136
for name in ambiguous { - 137
index.remove(&name); - 138
} - 139
index - 140
} - 141
- 142
/// A hook read from its declaration. One the reader rejects is dropped when - 143
/// it was advisory (fail-open) and, when it was declared fail-closed — - 144
/// or its failure mode is itself unreadable, so it may have been — becomes a - 145
/// refusal of every tool call: a guard whose definition cannot be read must - 146
/// not quietly stop guarding. `Core::capability_diagnostics` names it. - 147
fn hook_from_declaration(cap: &Capability) -> Option<HookDef> { - 148
let config: vak_config::HookConfig = serde_json::from_value(cap.configuration.clone()).ok()?; - 149
match crate::hook_def(&config) { - 150
Ok(def) => Some(def), - 151
Err(reason) => { - 152
let declared_open = config.failure_mode.as_deref().unwrap_or("open") == "open"; - 153
(!declared_open).then(|| HookDef { - 154
event: HookEvent::PreToolUse, - 155
matcher: None, - 156
command: config.command.clone(), - 157
timeout_ms: 0, - 158
failure_mode: HookFailureMode::Closed, - 159
refusal: Some(format!( - 160
"fail-closed hook `{}` cannot be read ({reason}); fix it to resume tool use", - 161
cap.id.name - 162
)), - 163
}) - 164
} - 165
} - 166
} - 167
- 168
impl TurnCapabilities { - 169
/// Build the admitted set for one turn in a single pass. - 170
pub fn build(probe: &TurnProbe<'_>) -> Self { - 171
let blocked = blocked_ids(probe.reach_standings); - 172
let surviving: Vec<&Capability> = probe - 173
.capabilities - 174
.usable() - 175
.filter(|cap| admitted(cap, probe, &blocked)) - 176
.collect(); - 177
let of_kind = |kind: CapabilityKind| { - 178
surviving - 179
.iter() - 180
.copied() - 181
.filter(move |cap| cap.id.kind == kind) - 182
}; - 183
- 184
let mcp_servers: BTreeSet<String> = of_kind(CapabilityKind::McpServer) - 185
.map(|cap| cap.id.name.clone()) - 186
.collect(); - 187
let mcp_tool_index = mcp_tool_index(probe.mcp_inventory, &mcp_servers, probe.builtin_names); - 188
- 189
let hooks = of_kind(CapabilityKind::Hook) - 190
.filter_map(hook_from_declaration) - 191
.collect(); - 192
- 193
let frozen_skills = of_kind(CapabilityKind::Skill) - 194
.filter_map(|cap| { - 195
Some(crate::skills::FrozenSkill { - 196
name: cap.id.name.clone(), - 197
description: cap.summary.clone(), - 198
path: cap.source.clone()?, - 199
digest: cap.digest.clone()?, - 200
provenance: Some(cap.origin.label()), - 201
}) - 202
}) - 203
.collect(); - 204
- 205
let tool_names: BTreeSet<String> = of_kind(CapabilityKind::Tool) - 206
.map(|cap| cap.id.name.clone()) - 207
.collect(); - 208
- 209
TurnCapabilities { - 210
flow_admitted: tool_names.contains("flow"), - 211
tool_names, - 212
descriptors: surviving.iter().map(|cap| cap.to_descriptor()).collect(), - 213
mcp_tool_index, - 214
mcp_server_names: mcp_servers.into_iter().collect(), - 215
hooks, - 216
frozen_skills, - 217
} - 218
} - 219
} - 220
- 221
#[cfg(test)] - 222
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 223
mod tests { - 224
use super::*; - 225
use crate::capability::domain::Serves; - 226
use crate::capability::resolution::Resolution; - 227
use crate::capability::snapshot::Origin; - 228
- 229
fn cap(name: &str, kind: CapabilityKind) -> Capability { - 230
Capability { - 231
id: CapabilityId::new(kind, name), - 232
origin: Origin::Builtin, - 233
summary: String::new(), - 234
serves: Serves::Undeclared, - 235
digest: None, - 236
source: None, - 237
resolution: Resolution::Available, - 238
configuration: serde_json::Value::Null, - 239
} - 240
} - 241
- 242
fn skill(name: &str) -> Capability { - 243
Capability { - 244
summary: format!("Skill {name}"), - 245
digest: Some(format!("sha:{name}")), - 246
source: Some(std::path::PathBuf::from(format!("/tmp/{name}/SKILL.md"))), - 247
..cap(name, CapabilityKind::Skill) - 248
} - 249
} - 250
- 251
fn hook(name: &str, config: serde_json::Value) -> Capability { - 252
Capability { - 253
configuration: config, - 254
..cap(name, CapabilityKind::Hook) - 255
} - 256
} - 257
- 258
fn server_tools(server: &str, tools: &[&str]) -> (String, Vec<vak_mcp::McpToolInfo>) { - 259
( - 260
server.to_string(), - 261
tools - 262
.iter() - 263
.map(|name| vak_mcp::McpToolInfo { - 264
name: (*name).into(), - 265
description: String::new(), - 266
input_schema: serde_json::json!({}), - 267
}) - 268
.collect(), - 269
) - 270
} - 271
- 272
fn blocked(kind: CapabilityKind, name: &str) -> crate::reach::Standing { - 273
crate::reach::Standing { - 274
id: CapabilityId::new(kind, name), - 275
tool: name.into(), - 276
label: name.into(), - 277
reach: crate::reach::Reach::Blocked, - 278
reason: "denied".into(), - 279
remedy: String::new(), - 280
} - 281
} - 282
- 283
struct Fixture { - 284
set: CapabilitySet, - 285
policy: ChannelPolicy, - 286
standings: Vec<crate::reach::Standing>, - 287
inventory: Vec<(String, Vec<vak_mcp::McpToolInfo>)>, - 288
revoked: BTreeSet<CapabilityId>, - 289
builtins: BTreeSet<String>, - 290
} - 291
- 292
impl Fixture { - 293
fn new(caps: Vec<Capability>) -> Self { - 294
Fixture { - 295
set: CapabilitySet::new(1, caps), - 296
policy: ChannelPolicy::default(), - 297
standings: Vec::new(), - 298
inventory: Vec::new(), - 299
revoked: BTreeSet::new(), - 300
builtins: ["read", "bash"].iter().map(|s| s.to_string()).collect(), - 301
} - 302
} - 303
- 304
fn build(&self) -> TurnCapabilities { - 305
TurnCapabilities::build(&TurnProbe { - 306
capabilities: &self.set, - 307
revoked_ids: self.revoked.clone(), - 308
channel_policy: &self.policy, - 309
reach_standings: &self.standings, - 310
mcp_inventory: &self.inventory, - 311
builtin_names: &self.builtins, - 312
}) - 313
} - 314
} - 315
- 316
/// Admission is policy only: nothing is removed for serving a domain the - 317
/// turn's reading did not predict — that is the surface's decision. - 318
#[test] - 319
fn every_admitted_kind_survives_regardless_of_domain() { - 320
let mut bash = cap("bash", CapabilityKind::Tool); - 321
bash.serves = Serves::from_labels(&["code-exec"]); - 322
let fixture = Fixture::new(vec![ - 323
bash, - 324
cap("flow", CapabilityKind::Tool), - 325
cap("search", CapabilityKind::McpServer), - 326
skill("travel"), - 327
hook( - 328
"stop/notify", - 329
serde_json::json!({"event": "stop", "command": "notify"}), - 330
), - 331
]); - 332
let tc = fixture.build(); - 333
assert!(tc.tool_names.contains("bash")); - 334
assert!(tc.flow_admitted); - 335
assert_eq!(tc.mcp_server_names, vec!["search"]); - 336
assert_eq!(tc.frozen_skills.len(), 1); - 337
assert_eq!(tc.hooks.len(), 1); - 338
assert_eq!(tc.descriptors.len(), 5); - 339
} - 340
- 341
#[test] - 342
fn channel_policy_removes_each_kind() { - 343
let mut fixture = Fixture::new(vec![ - 344
cap("bash", CapabilityKind::Tool), - 345
cap("search", CapabilityKind::McpServer), - 346
skill("travel"), - 347
]); - 348
fixture.policy.tools_deny = vec!["bash".into()]; - 349
fixture.policy.mcp_deny = vec!["search/*".into()]; - 350
fixture.policy.skills_deny = vec!["travel".into()]; - 351
let tc = fixture.build(); - 352
assert!(tc.tool_names.is_empty()); - 353
assert!(tc.mcp_server_names.is_empty()); - 354
assert!(tc.frozen_skills.is_empty()); - 355
assert!(tc.descriptors.is_empty()); - 356
} - 357
- 358
#[test] - 359
fn a_tool_scoped_mcp_allow_keeps_its_server() { - 360
let mut fixture = Fixture::new(vec![cap("search", CapabilityKind::McpServer)]); - 361
fixture.policy.mcp_allow = Some(vec!["search/query".into()]); - 362
fixture.inventory = vec![server_tools("search", &["query"])]; - 363
let tc = fixture.build(); - 364
assert_eq!( - 365
tc.mcp_tool_index.get("query").map(String::as_str), - 366
Some("search") - 367
); - 368
} - 369
- 370
#[test] - 371
fn a_blocked_standing_removes_exactly_that_capability() { - 372
let mut fixture = Fixture::new(vec![ - 373
cap("search", CapabilityKind::McpServer), - 374
cap("notes", CapabilityKind::McpServer), - 375
cap("webfetch", CapabilityKind::Tool), - 376
]); - 377
fixture.standings = vec![ - 378
blocked(CapabilityKind::McpServer, "search"), - 379
blocked(CapabilityKind::Tool, "webfetch"), - 380
]; - 381
let tc = fixture.build(); - 382
assert_eq!(tc.mcp_server_names, vec!["notes"]); - 383
assert!(tc.tool_names.is_empty()); - 384
} - 385
- 386
#[test] - 387
fn a_revoked_capability_is_gone_before_the_next_epoch() { - 388
let mut fixture = Fixture::new(vec![cap("webfetch", CapabilityKind::Tool)]); - 389
fixture - 390
.revoked - 391
.insert(CapabilityId::new(CapabilityKind::Tool, "webfetch")); - 392
assert!(fixture.build().tool_names.is_empty()); - 393
} - 394
- 395
#[test] - 396
fn the_mcp_index_drops_collisions_and_never_shadows_a_builtin() { - 397
let mut fixture = Fixture::new(vec![ - 398
cap("a", CapabilityKind::McpServer), - 399
cap("b", CapabilityKind::McpServer), - 400
]); - 401
fixture.inventory = vec![ - 402
server_tools("a", &["search", "only_a", "read"]), - 403
server_tools("b", &["search"]), - 404
server_tools("not_admitted", &["hidden"]), - 405
]; - 406
let index = fixture.build().mcp_tool_index; - 407
assert_eq!(index.get("only_a").map(String::as_str), Some("a")); - 408
assert!(!index.contains_key("search"), "ambiguous across servers"); - 409
assert!( - 410
!index.contains_key("read"), - 411
"a built-in is never an MCP alias" - 412
); - 413
assert!(!index.contains_key("hidden")); - 414
} - 415
- 416
#[test] - 417
fn a_broken_advisory_hook_is_dropped() { - 418
let fixture = Fixture::new(vec![hook( - 419
"pre/bad", - 420
serde_json::json!({"event": "pre-tool-use", "match": "((", "command": "x"}), - 421
)]); - 422
assert!(fixture.build().hooks.is_empty()); - 423
} - 424
- 425
#[test] - 426
fn a_broken_fail_closed_hook_refuses_instead_of_vanishing() { - 427
let fixture = Fixture::new(vec![hook( - 428
"pre/guard", - 429
serde_json::json!({ - 430
"event": "pre-tool-use", - 431
"match": "((", - 432
"command": "guard", - 433
"failure_mode": "closed", - 434
}), - 435
)]); - 436
let hooks = fixture.build().hooks; - 437
assert_eq!(hooks.len(), 1); - 438
assert_eq!(hooks[0].event, HookEvent::PreToolUse); - 439
assert!( - 440
hooks[0].matcher.is_none(), - 441
"refuses every tool it might guard" - 442
); - 443
assert!(hooks[0].refusal.as_deref().unwrap().contains("pre/guard")); - 444
} - 445
- 446
#[test] - 447
fn a_valid_hook_reads_through_the_shared_reader() { - 448
let fixture = Fixture::new(vec![hook( - 449
"pre/lint", - 450
serde_json::json!({ - 451
"event": "pre-tool-use", - 452
"match": "bash", - 453
"command": "lint", - 454
"timeout_ms": 500, - 455
"failure_mode": "closed", - 456
}), - 457
)]); - 458
let hooks = fixture.build().hooks; - 459
assert_eq!(hooks.len(), 1); - 460
assert!(hooks[0].matcher.is_some()); - 461
assert_eq!(hooks[0].timeout_ms, 500); - 462
assert_eq!(hooks[0].failure_mode, HookFailureMode::Closed); - 463
assert!(hooks[0].refusal.is_none()); - 464
} - 465
} - 466
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.