- 12650
vak_sandbox::promote_recoverable(&candidate, &promotion_root) - 12651
}) - 12652
.await - 12653
{ - 12654
Ok(Ok(receipt)) => receipt, - 12655
Ok(Err(error)) => { - 12656
return ( - 12657
StatusCode::CONFLICT, - 12658
Json(serde_json::json!({ "error": error.to_string() })), - 12659
) - 12660
.into_response(); - 12661
} - 12662
Err(error) => { - 12663
return ( - 12664
StatusCode::INTERNAL_SERVER_ERROR, - 12665
Json(serde_json::json!({ "error": format!("promotion worker failed: {error}") })), - 12666
) - 12667
.into_response(); - 12668
} - 12669
}; - 12670
let checks = vak_tools::broker::verify_targets( - 12671
&state.core.tool_worker_exe(), - 12672
&applied_root, - 12673
&planned_checks, - 12674
) - 12675
.await; - 12676
if !checks.is_empty() { - 12677
let failed = checks.iter().any(|check| check.status == "failed"); - 12678
receipt.integration.target_checks_status = if failed { "failed" } else { "passed" }.into(); - 12679
receipt.integration.evidence = format!( - 12680
"{} registered target format check(s) ran against applied state {}", - 12681
checks.len(), - 12682
receipt.integration.applied_state_digest - 12683
); - 12684
receipt.integration.target_checks = checks; - 12685
} - 12686
let record = vak_sandbox::DurableRecord::Promotion(vak_sandbox::PromotionRecord { - 12687
record_id: format!("promotion-{}", receipt.candidate_id), - 12688
session_id, - 12689
result_id: saved.result_id, - 12690
candidate_digest: saved.candidate_digest, - 12691
candidate_id: receipt.candidate_id.clone(), - 12692
receipt: receipt.clone(), - 12693
workspace_checks: selected_workspace_checks, - 12694
updated_at: chrono::Utc::now().to_rfc3339(), - 12695
}); - 12696
if let Err(error) = vak_sandbox::append_record(&sandbox_records_path(&state), &record) { - 12697
return ( - 12698
StatusCode::INTERNAL_SERVER_ERROR, - 12699
Json(serde_json::json!({ "error": error.to_string() })), - 12700
) - 12701
.into_response(); - 12702
} - 12703
(StatusCode::OK, Json(record)).into_response() - 12704
} - 12705
- 12706
async fn undo_sandbox_promotion( - 12707
State(state): State<AppState>, - 12708
Path((session_id, candidate_id)): Path<(String, String)>, - 12709
) -> axum::response::Response { - 12710
use axum::response::IntoResponse; - 12711
let records_path = sandbox_records_path(&state); - 12712
let records = match vak_sandbox::load_records(&records_path) { - 12713
Ok(records) => records, - 12714
Err(error) => { - 12715
return ( - 12716
StatusCode::INTERNAL_SERVER_ERROR, - 12717
Json(serde_json::json!({ "error": error.to_string() })), - 12718
) - 12719
.into_response(); - 12720
} - 12721
}; - 12722
if let Some(existing) = records.iter().rev().find_map(|record| match record { - 12723
vak_sandbox::DurableRecord::PromotionUndo(record) - 12724
if record.session_id == session_id && record.candidate_id == candidate_id => - 12725
{ - 12726
Some(record.clone()) - 12727
} - 12728
_ => None, - 12729
}) { - 12730
return Json(vak_sandbox::DurableRecord::PromotionUndo(existing)).into_response(); - 12731
} - 12732
let promoted = records.iter().any(|record| { - 12733
matches!( - 12734
record, - 12735
vak_sandbox::DurableRecord::Promotion(record) - 12736
if record.session_id == session_id && record.candidate_id == candidate_id - 12737
) - 12738
}); - 12739
if !promoted { - 12740
return (StatusCode::NOT_FOUND, "applied candidate not found").into_response(); - 12741
} - 12742
let promotion_root = sandbox_promotions_root(&state); - 12743
let undo_id = candidate_id.clone(); - 12744
let receipt = match tokio::task::spawn_blocking(move || { - 12745
vak_sandbox::undo_promotion(&undo_id, &promotion_root) - 12746
}) - 12747
.await - 12748
{ - 12749
Ok(Ok(receipt)) => receipt, - 12750
Ok(Err(error)) => { - 12751
return ( - 12752
StatusCode::CONFLICT, - 12753
Json(serde_json::json!({ "error": error.to_string() })), - 12754
) - 12755
.into_response(); - 12756
} - 12757
Err(error) => { - 12758
return ( - 12759
StatusCode::INTERNAL_SERVER_ERROR, - 12760
Json(serde_json::json!({ "error": format!("undo worker failed: {error}") })), - 12761
) - 12762
.into_response(); - 12763
} - 12764
}; - 12765
let record = vak_sandbox::PromotionUndoRecord { - 12766
record_id: format!("promotion-undo-{candidate_id}"), - 12767
session_id, - 12768
candidate_id, - 12769
receipt, - 12770
updated_at: chrono::Utc::now().to_rfc3339(), - 12771
}; - 12772
let durable = vak_sandbox::DurableRecord::PromotionUndo(record); - 12773
if let Err(error) = vak_sandbox::append_record(&records_path, &durable) { - 12774
return ( - 12775
StatusCode::INTERNAL_SERVER_ERROR, - 12776
Json(serde_json::json!({ "error": error.to_string() })), - 12777
) - 12778
.into_response(); - 12779
} - 12780
(StatusCode::OK, Json(durable)).into_response() - 12781
} - 12782
- 12783
#[derive(Debug, serde::Deserialize)] - 12784
struct WorkspaceCheckBody { - 12785
check_id: String, - 12786
} - 12787
- 12788
async fn run_sandbox_workspace_check( - 12789
State(state): State<AppState>, - 12790
Path((session_id, candidate_id)): Path<(String, String)>, - 12791
Json(body): Json<WorkspaceCheckBody>, - 12792
) -> axum::response::Response { - 12793
use axum::response::IntoResponse; - 12794
let records_path = sandbox_records_path(&state); - 12795
let records = match vak_sandbox::load_records(&records_path) { - 12796
Ok(records) => records, - 12797
Err(error) => { - 12798
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(); - 12799
} - 12800
}; - 12801
if records.iter().any(|record| matches!(record, vak_sandbox::DurableRecord::PromotionUndo(undo) if undo.session_id == session_id && undo.candidate_id == candidate_id)) { - 12802
return (StatusCode::CONFLICT, "candidate acceptance was undone").into_response(); - 12803
} - 12804
let Some(mut candidate) = records.iter().rev().find_map(|record| match record { - 12805
vak_sandbox::DurableRecord::Candidate(value) - 12806
if value.session_id == session_id && value.candidate.candidate_id == candidate_id => - 12807
{ - 12808
Some(value.candidate.clone()) - 12809
} - 12810
_ => None, - 12811
}) else { - 12812
return (StatusCode::NOT_FOUND, "candidate not found").into_response(); - 12813
}; - 12814
let Some(promotion) = records.iter().rev().find_map(|record| match record { - 12815
vak_sandbox::DurableRecord::Promotion(value) - 12816
if value.session_id == session_id && value.candidate_id == candidate_id => - 12817
{ - 12818
Some(value.clone()) - 12819
} - 12820
_ => None, - 12821
}) else { - 12822
return (StatusCode::CONFLICT, "candidate has not been accepted").into_response(); - 12823
}; - 12824
candidate - 12825
.files - 12826
.retain(|file| promotion.receipt.applied.contains(&file.path)); - 12827
candidate - 12828
.target_checks - 12829
.retain(|check| promotion.receipt.applied.contains(&check.path)); - 12830
let Some(check) = promotion - 12831
.workspace_checks - 12832
.iter() - 12833
.find(|check| check.id == body.check_id) - 12834
.cloned() - 12835
else { - 12836
return (StatusCode::BAD_REQUEST, "unknown workspace check").into_response(); - 12837
}; - 12838
if !planned_workspace_checks(&candidate).contains(&check) { - 12839
return ( - 12840
StatusCode::CONFLICT, - 12841
"workspace check no longer matches accepted files", - 12842
) - 12843
.into_response(); - 12844
} - 12845
let candidate_for_state = candidate.clone(); - 12846
let promotion_root = sandbox_promotions_root(&state); - 12847
match tokio::task::spawn_blocking(move || { - 12848
vak_sandbox::promote_recoverable(&candidate_for_state, &promotion_root) - 12849
}) - 12850
.await - 12851
{ - 12852
Ok(Ok(receipt)) - 12853
if receipt.integration.applied_state_digest - 12854
== promotion.receipt.integration.applied_state_digest => {} - 12855
Ok(Ok(_)) => { - 12856
return (StatusCode::CONFLICT, "applied state identity changed").into_response(); - 12857
} - 12858
Ok(Err(error)) => return (StatusCode::CONFLICT, error.to_string()).into_response(), - 12859
Err(error) => { - 12860
return ( - 12861
StatusCode::INTERNAL_SERVER_ERROR, - 12862
format!("workspace state worker failed: {error}"), - 12863
) - 12864
.into_response(); - 12865
} - 12866
} - 12867
let outcome = execute_script(&state.core, &candidate.destination_root, &check.command).await; - 12868
let candidate_after_check = candidate; - 12869
let promotion_root = sandbox_promotions_root(&state); - 12870
let expected_digest = promotion.receipt.integration.applied_state_digest.clone(); - 12871
let state_after_check = tokio::task::spawn_blocking(move || { - 12872
vak_sandbox::promote_recoverable(&candidate_after_check, &promotion_root) - 12873
}) - 12874
.await; - 12875
let state_evidence = match state_after_check { - 12876
Ok(Ok(receipt)) if receipt.integration.applied_state_digest == expected_digest => None, - 12877
Ok(Ok(_)) => Some("accepted workspace state changed during this check".to_string()), - 12878
Ok(Err(error)) => Some(format!( - 12879
"accepted workspace state changed during this check: {error}" - 12880
)), - 12881
Err(error) => Some(format!( - 12882
"accepted workspace state could not be verified after this check: {error}" - 12883
)), - 12884
}; - 12885
let evidence = match state_evidence.as_deref() { - 12886
Some(reason) if outcome.text.is_empty() => reason.to_string(), - 12887
Some(reason) => format!("{}\n\n{reason}", outcome.text), - 12888
None => outcome.text, - 12889
}; - 12890
let record = vak_sandbox::WorkspaceCheckRecord { - 12891
record_id: format!("workspace-check-{}", uuid::Uuid::now_v7()), - 12892
session_id, - 12893
candidate_id, - 12894
applied_state_digest: promotion.receipt.integration.applied_state_digest, - 12895
check, - 12896
status: if outcome.ok && state_evidence.is_none() { - 12897
"passed" - 12898
} else { - 12899
"failed" - 12900
} - 12901
.into(), - 12902
evidence, - 12903
updated_at: chrono::Utc::now().to_rfc3339(), - 12904
}; - 12905
let durable = vak_sandbox::DurableRecord::WorkspaceCheck(record); - 12906
match vak_sandbox::append_record(&records_path, &durable) { - 12907
Ok(()) => Json(durable).into_response(), - 12908
Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), - 12909
} - 12910
} - 12911
- 12912
#[derive(serde::Deserialize)] - 12913
struct ModeBody { - 12914
mode: String, - 12915
#[serde(default)] - 12916
agent: Option<String>, - 12917
} - 12918
- 12919
/// Accepts every spelling clients use: config kebab-case (`workspace-write`) - 12920
/// and the Debug format surfaced by `/health` + `/config` (`WorkspaceWrite`). - 12921
pub(crate) fn parse_mode(raw: &str) -> Option<vak_config::PermissionMode> { - 12922
vak_config::PermissionMode::deserialize_str(raw).or(match raw { - 12923
"ReadOnly" => Some(vak_config::PermissionMode::ReadOnly), - 12924
"WorkspaceWrite" => Some(vak_config::PermissionMode::WorkspaceWrite), - 12925
"FullAccess" => Some(vak_config::PermissionMode::FullAccess), - 12926
_ => None, - 12927
}) - 12928
} - 12929
- 12930
pub(crate) fn parse_approval_mode(raw: &str) -> Option<vak_config::ApprovalMode> { - 12931
vak_config::ApprovalMode::parse(raw).or(match raw { - 12932
"Ask" => Some(vak_config::ApprovalMode::Ask), - 12933
"ApproveSafe" => Some(vak_config::ApprovalMode::ApproveSafe), - 12934
"AutoApprove" => Some(vak_config::ApprovalMode::AutoApprove), - 12935
_ => None, - 12936
}) - 12937
} - 12938
- 12939
async fn set_permission_mode( - 12940
State(state): State<AppState>, - 12941
Json(body): Json<ModeBody>, - 12942
) -> axum::response::Response { - 12943
use axum::response::IntoResponse; - 12944
// `scoped_core!` forwards `resolve_scoped_core`'s own status (e.g. 409 - 12945
// CONFLICT for a paused/archived Agent) instead of collapsing every - 12946
// resolution error into 404, the way this handler used to. - 12947
let core = scoped_core!(&state, None, body.agent.as_deref()); - 12948
match parse_mode(&body.mode) { - 12949
Some(mode) => { - 12950
let old = core.effective_permission_mode(); - 12951
if vak_config::persist_project_preferences( - 12952
core.cwd(), - 12953
None, - 12954
None, - 12955
None, - 12956
Some(mode), - 12957
None, - 12958
None, - 12959
) - 12960
.is_err() - 12961
{ - 12962
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 12963
} - 12964
apply_permission_mode(&core, &state, mode, true); - 12965
if old != mode { - 12966
vak_core::security_events::record( - 12967
&core.sessions_home(), - 12968
vak_core::security_events::EventKind::ConfigChange, - 12969
"permission_mode_changed", - 12970
&format!("{old:?} -> {mode:?}"), - 12971
None, - 12972
); - 12973
state - 12974
.hub - 12975
.emit_config_changed("permission_mode", &format!("{mode:?}")); - 12976
} - 12977
StatusCode::OK.into_response() - 12978
} - 12979
None => StatusCode::BAD_REQUEST.into_response(), - 12980
} - 12981
} - 12982
- 12983
// ---- Gateway approval policy ---------------------------------------------- - 12984
- 12985
/// `GET /gateway/approvals` — the live policy plus the chats that could - 12986
/// answer a gate. - 12987
/// - 12988
/// The candidate list is what makes this usable: `approver` is a - 12989
/// `<surface>:<chat>` target, and an operator has no way to type one - 12990
/// correctly from memory. Every allowed allowlist entry is offered, - 12991
/// normalized to the two-part shape `deliver_to` uses, because a - 12992
/// bot-scoped three-part key is an allowlist identity and not a delivery - 12993
/// address. - 12994
async fn get_gateway_approvals(State(state): State<AppState>) -> Json<serde_json::Value> { - 12995
Json(serde_json::json!({ - 12996
"mode": state.gateway.approvals_mode(), - 12997
"approver": state.gateway.approver_target(), - 12998
"timeout_secs": state.gateway.approval_timeout().as_secs(), - 12999
"enabled": state.gateway.enabled, - 13000
"forwarding": state.gateway.forward_mode(), - 13001
"candidates": state.gateway.approver_candidates(), - 13002
})) - 13003
} - 13004
- 13005
#[derive(serde::Deserialize)] - 13006
struct GatewayApprovalsBody { - 13007
/// "deny" or "forward". - 13008
mode: String, - 13009
/// `<surface>:<chat>`. Required for "forward"; ignored for "deny". - 13010
#[serde(default)] - 13011
approver: Option<String>, - 13012
/// Seconds a forwarded gate waits before failing closed. Minimum 5, - 13013
/// matching `vak_config`'s own floor. - 13014
#[serde(default)] - 13015
timeout_secs: Option<u64>, - 13016
#[serde(default)] - 13017
scope: Option<ConfigScope>, - 13018
} - 13019
- 13020
/// `PUT /gateway/approvals` — set the policy, live and on disk. - 13021
/// - 13022
/// Validation happens here rather than being left to the config loader's - 13023
/// fallback: the loader's job is to make a bad file safe (it degrades - 13024
/// `forward` with no target to `deny` and warns), but an operator pressing - 13025
/// a button deserves a refusal that names the problem instead of a success - 13026
/// followed by a silently different setting. - 13027
async fn put_gateway_approvals( - 13028
State(state): State<AppState>, - 13029
Json(body): Json<GatewayApprovalsBody>, - 13030
) -> axum::response::Response { - 13031
use axum::response::IntoResponse; - 13032
let bad = |msg: &str| { - 13033
( - 13034
StatusCode::BAD_REQUEST, - 13035
Json(serde_json::json!({ "error": msg })), - 13036
) - 13037
.into_response() - 13038
}; - 13039
let mode = body.mode.trim(); - 13040
if !matches!(mode, "deny" | "forward") { - 13041
return bad("mode must be \"deny\" or \"forward\""); - 13042
} - 13043
let approver = body - 13044
.approver - 13045
.as_deref() - 13046
.map(str::trim) - 13047
.filter(|t| !t.is_empty()); - 13048
if mode == "forward" { - 13049
match approver { - 13050
None => { - 13051
return bad( - 13052
"forwarding needs an approver chat — the gate is announced there and \ - 13053
answered with \"yes\" or \"no\"", - 13054
); - 13055
} - 13056
Some(target) if !target.contains(':') => { - 13057
return bad("approver must be \"<surface>:<chat>\", e.g. \"telegram:12345678\""); - 13058
} - 13059
Some(_) => {} - 13060
} - 13061
} - 13062
if let Some(secs) = body.timeout_secs - 13063
&& !(5..=86_400).contains(&secs) - 13064
{ - 13065
return bad("timeout must be between 5 and 86400 seconds"); - 13066
} - 13067
- 13068
let scope = body.scope.unwrap_or(ConfigScope::Workspace); - 13069
let path = match scope.config_path(&state.core) { - 13070
Ok(path) => path, - 13071
Err(error) => { - 13072
return ( - 13073
StatusCode::INTERNAL_SERVER_ERROR, - 13074
Json(serde_json::json!({ "error": error })), - 13075
) - 13076
.into_response(); - 13077
} - 13078
}; - 13079
// Persist first. A policy that applied live but never reached disk is - 13080
// exactly the "I set it and it reverted" failure this endpoint exists - 13081
// to end, and it is worse than one that failed loudly. - 13082
if let Err(error) = vak_config::persist_gateway_approvals( - 13083
path, - 13084
Some(mode), - 13085
// "deny" clears the target rather than leaving a stale one behind - 13086
// that a later "forward" would silently reuse. - 13087
Some(if mode == "forward" { approver } else { None }), - 13088
body.timeout_secs, - 13089
) { - 13090
return ( - 13091
StatusCode::INTERNAL_SERVER_ERROR, - 13092
Json(serde_json::json!({ "error": error.to_string() })), - 13093
) - 13094
.into_response(); - 13095
} - 13096
- 13097
let previous = state.gateway.approvals_mode(); - 13098
let installed = state - 13099
.gateway - 13100
.set_approval_policy(crate::gateway::ApprovalPolicy { - 13101
approvals: mode.to_string(), - 13102
approver: approver.map(str::to_string), - 13103
timeout: std::time::Duration::from_secs( - 13104
body.timeout_secs - 13105
.unwrap_or_else(|| state.gateway.approval_timeout().as_secs()), - 13106
), - 13107
}); - 13108
- 13109
if previous != installed.approvals || installed.approvals == "forward" { - 13110
vak_core::security_events::record( - 13111
&state.core.sessions_home(), - 13112
vak_core::security_events::EventKind::ConfigChange, - 13113
"gateway_approvals_changed", - 13114
&format!( - 13115
"{previous} -> {} approver={} scope={}", - 13116
installed.approvals, - 13117
installed.approver.as_deref().unwrap_or("<none>"), - 13118
scope.label() - 13119
), - 13120
None, - 13121
); - 13122
state - 13123
.hub - 13124
.emit_config_changed("gateway_approvals", &installed.approvals); - 13125
} - 13126
- 13127
( - 13128
StatusCode::OK, - 13129
Json(serde_json::json!({ - 13130
"mode": installed.approvals, - 13131
"approver": installed.approver, - 13132
"timeout_secs": installed.timeout.as_secs(), - 13133
"forwarding": state.gateway.forward_mode(), - 13134
// The gateway being off makes a forward policy inert. Say so - 13135
// rather than reporting a grant the next inbound turn will not - 13136
// honour, which is the same class of lie `reach` exists to end. - 13137
"gateway_enabled": state.gateway.enabled, - 13138
})), - 13139
) - 13140
.into_response() - 13141
} - 13142
- 13143
// ---- Permission rules ------------------------------------------------------ - 13144
- 13145
/// `GET /config/permissions` — the effective rule lists the engine - 13146
/// evaluates, plus the selected layer's own, so a reader can tell an - 13147
/// inherited rule from one this scope set. - 13148
async fn get_permission_rules( - 13149
State(state): State<AppState>, - 13150
axum::extract::Query(q): axum::extract::Query<OptionalScopeQuery>, - 13151
) -> axum::response::Response { - 13152
use axum::response::IntoResponse; - 13153
let core = scoped_core!(&state, None, q.agent.as_deref()); - 13154
let scope = q.scope.unwrap_or(ConfigScope::Workspace); - 13155
// Recomputed from this resolved Core's own effective rules on every - 13156
// request rather than relying on any process-pinned cache — a Core - 13157
// resolved for a non-default Agent must not read the default Agent's - 13158
// runtime-pinned overrides, and vice versa. - 13159
let (allow, ask, deny) = core.effective_permission_rules(); - 13160
let layer = match scope - 13161
.config_path(&core) - 13162
.and_then(|path| read_config_layer(path.as_path())) - 13163
{ - 13164
Ok(layer) => layer, - 13165
Err(error) => { - 13166
return ( - 13167
StatusCode::INTERNAL_SERVER_ERROR, - 13168
Json(serde_json::json!({ "error": error })), - 13169
) - 13170
.into_response(); - 13171
} - 13172
}; - 13173
( - 13174
StatusCode::OK, - 13175
Json(serde_json::json!({ - 13176
"scope": scope.label(), - 13177
"effective": { "allow": allow, "ask": ask, "deny": deny }, - 13178
"layer": { - 13179
"allow": layer.allow, - 13180
"ask": layer.ask, - 13181
"deny": layer.deny, - 13182
}, - 13183
})), - 13184
) - 13185
.into_response() - 13186
} - 13187
- 13188
#[derive(serde::Deserialize)] - 13189
struct PermissionRulesBody { - 13190
/// Absent leaves that list alone; present replaces it wholesale. - 13191
#[serde(default)] - 13192
allow: Option<Vec<String>>, - 13193
#[serde(default)] - 13194
ask: Option<Vec<String>>, - 13195
#[serde(default)] - 13196
deny: Option<Vec<String>>, - 13197
#[serde(default)] - 13198
scope: Option<ConfigScope>, - 13199
#[serde(default)] - 13200
agent: Option<String>, - 13201
} - 13202
- 13203
/// `PUT /config/permissions` — replace rule lists in one layer. - 13204
/// - 13205
/// Every spec is parsed through the real `vak_permission::Rule::parse` - 13206
/// before anything is written, and the whole request is rejected if any - 13207
/// one of them fails. A half-applied rule set is a permission decision - 13208
/// nobody chose. - 13209
async fn put_permission_rules( - 13210
State(state): State<AppState>, - 13211
Json(body): Json<PermissionRulesBody>, - 13212
) -> axum::response::Response { - 13213
use axum::response::IntoResponse; - 13214
let core = scoped_core!(&state, None, body.agent.as_deref()); - 13215
for (list_name, list) in [ - 13216
("allow", &body.allow), - 13217
("ask", &body.ask), - 13218
("deny", &body.deny), - 13219
] { - 13220
let Some(list) = list else { continue }; - 13221
for spec in list { - 13222
if let Err(error) = vak_permission::Rule::parse(spec) { - 13223
return ( - 13224
StatusCode::BAD_REQUEST, - 13225
Json(serde_json::json!({ - 13226
"error": format!("{list_name}: {error}"), - 13227
"rule": spec, - 13228
})), - 13229
) - 13230
.into_response(); - 13231
} - 13232
} - 13233
} - 13234
let scope = body.scope.unwrap_or(ConfigScope::Workspace); - 13235
let path = match scope.config_path(&core) { - 13236
Ok(path) => path, - 13237
Err(error) => { - 13238
return ( - 13239
StatusCode::INTERNAL_SERVER_ERROR, - 13240
Json(serde_json::json!({ "error": error })), - 13241
) - 13242
.into_response(); - 13243
} - 13244
}; - 13245
if let Err(error) = vak_config::persist_permission_rules( - 13246
path, - 13247
body.allow.as_deref(), - 13248
body.ask.as_deref(), - 13249
body.deny.as_deref(), - 13250
) { - 13251
return ( - 13252
StatusCode::INTERNAL_SERVER_ERROR, - 13253
Json(serde_json::json!({ "error": error.to_string() })), - 13254
) - 13255
.into_response(); - 13256
} - 13257
// Re-merge both layers against this resolved Agent's own Core. Written - 13258
// rules are re-read from disk on every subsequent request through - 13259
// `resolve_scoped_core` (option (b): no pinned-cache optimization for a - 13260
// non-default Agent, since a freshly re-resolved Core would lose the - 13261
// pin anyway) — pinning onto the runtime override is kept only for the - 13262
// "vak" default/registered-session Core, where callers still read - 13263
// `effective_permission_rules()` off the very same long-lived instance - 13264
// within this same process. - 13265
let merged = match vak_config::load_with_trust(core.cwd(), core.project_config_trusted()) { - 13266
Ok(merged) => merged, - 13267
Err(error) => { - 13268
return ( - 13269
StatusCode::INTERNAL_SERVER_ERROR, - 13270
Json(serde_json::json!({ "error": error.to_string() })), - 13271
) - 13272
.into_response(); - 13273
} - 13274
}; - 13275
// Reject a set that the engine cannot compile, and do it BEFORE - 13276
// pinning: individually valid rules are all that was checked above, - 13277
// and the merge brings in the other layer's rules too. - 13278
let (allow, ask, deny) = ( - 13279
merged.allow.clone(), - 13280
merged.ask.clone(), - 13281
merged.deny.clone(), - 13282
); - 13283
core.apply_persisted_permission_rules(allow.clone(), ask.clone(), deny.clone()); - 13284
if let Err(error) = core.build_permission_engine(&[]) { - 13285
return ( - 13286
StatusCode::INTERNAL_SERVER_ERROR, - 13287
Json(serde_json::json!({ - 13288
"error": format!("merged rule set does not compile: {error}") - 13289
})), - 13290
) - 13291
.into_response(); - 13292
} - 13293
vak_core::security_events::record( - 13294
&core.sessions_home(), - 13295
vak_core::security_events::EventKind::ConfigChange, - 13296
"permission_rules_changed", - 13297
&format!( - 13298
"scope={} allow={} ask={} deny={}", - 13299
scope.label(), - 13300
allow.len(), - 13301
ask.len(), - 13302
deny.len() - 13303
), - 13304
None, - 13305
); - 13306
state - 13307
.hub - 13308
.emit_config_changed("permission_rules", scope.label()); - 13309
( - 13310
StatusCode::OK, - 13311
Json(serde_json::json!({ - 13312
"scope": scope.label(), - 13313
"effective": { "allow": allow, "ask": ask, "deny": deny }, - 13314
})), - 13315
) - 13316
.into_response() - 13317
} - 13318
- 13319
/// `core` is the Agent-scoped Core the mode is actually read from and - 13320
/// written to (so a PATCH scoped to a non-default Agent lands on that - 13321
/// Agent's own Core, not the process's default workspace); `state` is used - 13322
/// for the safety fallout below. Pooled channel Core invalidation stays - 13323
/// process-wide (those pooled Cores aren't cheaply attributable to one - 13324
/// Agent, and discarding an unaffected one just costs a fresh resolve on - 13325
/// its next message); but the live-session cancellation/approval-denial is - 13326
/// scoped to sessions running under *this* Core — a narrower ceiling must - 13327
/// not leave an already-running session under the same Agent holding a - 13328
/// wider one, but it has no bearing on a different Agent's own sessions. - 13329
fn apply_permission_mode( - 13330
core: &vak_core::Core, - 13331
state: &AppState, - 13332
mode: vak_config::PermissionMode, - 13333
persisted: bool, - 13334
) { - 13335
if core.effective_permission_mode() == mode { - 13336
return; - 13337
} - 13338
if persisted { - 13339
core.apply_persisted_permission_mode(mode); - 13340
} else { - 13341
core.set_permission_mode(mode); - 13342
} - 13343
// Warm per-channel instances captured their ceiling when they were - 13344
// built. Discard them so the next inbound message resolves a fresh one; - 13345
// without this a narrowed mode reached chats only when their idle - 13346
// window expired, which is up to half an hour of running under a - 13347
// ceiling that had already been revoked. - 13348
let dropped = state.gateway.core_pool.invalidate_pooled(); - 13349
if dropped > 0 { - 13350
vak_core::security_events::record( - 13351
&state.core.sessions_home(), - 13352
vak_core::security_events::EventKind::ConfigChange, - 13353
"core_pool_invalidated", - 13354
&format!("permission_mode={mode:?} dropped={dropped}"), - 13355
None, - 13356
); - 13357
} - 13358
let handles: Vec<Arc<SessionHandle>> = state - 13359
.sessions - 13360
.lock() - 13361
.unwrap_or_else(std::sync::PoisonError::into_inner) - 13362
.values() - 13363
.filter(|handle| handle.core.cwd() == core.cwd()) - 13364
.cloned() - 13365
.collect(); - 13366
for handle in handles { - 13367
handle - 13368
.cancel - 13369
.lock() - 13370
.unwrap_or_else(std::sync::PoisonError::into_inner) - 13371
.cancel(); - 13372
handle - 13373
.side_cancel - 13374
.lock() - 13375
.unwrap_or_else(std::sync::PoisonError::into_inner) - 13376
.cancel(); - 13377
deny_pending_approvals(&handle); - 13378
let _ = state.gateway.deny_pending_for_session(&handle.id); - 13379
} - 13380
} - 13381
- 13382
fn refresh_control_plane(state: &AppState) { - 13383
let old_mode = state.core.effective_permission_mode(); - 13384
if let Ok(mode) = state.core.refresh_persisted_preferences() - 13385
&& !state.core.permission_mode_runtime_pinned() - 13386
&& mode != old_mode - 13387
{ - 13388
apply_permission_mode(&state.core, state, mode, true); - 13389
state - 13390
.hub - 13391
.emit_config_changed("permission_mode_refreshed", &format!("{mode:?}")); - 13392
} - 13393
} - 13394
- 13395
/// Picker data for provider/model UIs. Reports WHICH env var authenticates - 13396
/// each provider and whether it resolves right now — never the value. - 13397
async fn list_providers(State(state): State<AppState>) -> Json<serde_json::Value> { - 13398
refresh_control_plane(&state); - 13399
let route = state.core.effective_route(); - 13400
let mut providers = Vec::new(); - 13401
for name in state.core.provider_names() { - 13402
let requires_key = name != "ollama"; - 13403
let configured = state.core.provider_configured(&name); - 13404
let credential_ids = state.core.provider_credential_ids(&name); - 13405
let (project_key, user_key, process_key) = state.core.provider_key_sources(&name); - 13406
providers.push(serde_json::json!({ - 13407
"label": Core::provider_label(&name).unwrap_or(&name), - 13408
"name": name, - 13409
"env_var": Core::provider_env_var(&name), - 13410
"pool_env_var": pool_env_var(&name), - 13411
"pool_size": credential_ids.len(), - 13412
"credential_ids": credential_ids, - 13413
"requires_key": requires_key, - 13414
"configured": configured, - 13415
"key_in_project": project_key, - 13416
"key_in_user": user_key, - 13417
"key_in_process": process_key, - 13418
"key_source": if project_key { "project" } else if user_key { "user" } else if process_key { "process" } else { "none" }, - 13419
})); - 13420
} - 13421
Json(serde_json::json!({ - 13422
"current": route.provider, - 13423
"current_model": route.model, - 13424
"current_provider_source": route.provider_source, - 13425
"current_model_source": route.model_source, - 13426
"route_revision": route.revision, - 13427
"current_configured": state.core.provider_configured(&state.core.effective_provider()), - 13428
"providers": providers, - 13429
})) - 13430
} - 13431
- 13432
fn pool_env_var(provider: &str) -> Option<&'static str> { - 13433
Core::provider_pool_env_var(provider) - 13434
} - 13435
- 13436
#[derive(serde::Deserialize)] - 13437
struct ProviderRef { - 13438
provider: String, - 13439
#[serde(default)] - 13440
scope: Option<ConfigScope>, - 13441
} - 13442
- 13443
/// Revoke a provider key. Reports when the variable is still set in the - 13444
/// real environment, since that keeps the provider authenticated and no - 13445
/// app-level action can change it. - 13446
async fn delete_provider_key( - 13447
State(state): State<AppState>, - 13448
Json(body): Json<ProviderRef>, - 13449
) -> axum::response::Response { - 13450
let scope = body.scope.unwrap_or(ConfigScope::User); - 13451
match state - 13452
.core - 13453
.remove_provider_key_scoped(&body.provider, scope.is_workspace()) - 13454
{ - 13455
Ok(removed) => { - 13456
vak_core::security_events::record( - 13457
&state.core.sessions_home(), - 13458
vak_core::security_events::EventKind::ProviderKeyChange, - 13459
"provider_key_removed", - 13460
&format!( - 13461
"provider={} scope={} shadowed={}", - 13462
body.provider, - 13463
scope.label(), - 13464
removed.shadowed_by_env - 13465
), - 13466
None, - 13467
); - 13468
state - 13469
.hub - 13470
.emit_config_changed("provider_key_removed", &body.provider); - 13471
Json(serde_json::json!({ - 13472
"provider": body.provider, - 13473
"env_var": removed.env_var, - 13474
"configured": removed.shadowed_by_env, - 13475
"shadowed_by_env": removed.shadowed_by_env, - 13476
})) - 13477
.into_response() - 13478
} - 13479
Err(e) => ( - 13480
StatusCode::BAD_REQUEST, - 13481
Json(serde_json::json!({ "error": e.to_string() })), - 13482
) - 13483
.into_response(), - 13484
} - 13485
} - 13486
- 13487
/// Live model list for one provider, straight from its API using the key - 13488
/// currently configured for it. Reports the failure reason rather than - 13489
/// substituting a stale hard-coded list. - 13490
async fn discover_models( - 13491
State(state): State<AppState>, - 13492
axum::extract::Path(name): axum::extract::Path<String>, - 13493
) -> axum::response::Response { - 13494
if !Core::provider_known(&name) { - 13495
return ( - 13496
StatusCode::NOT_FOUND, - 13497
Json(serde_json::json!({ "error": format!("unknown provider '{name}'") })), - 13498
) - 13499
.into_response(); - 13500
} - 13501
match state.core.discover_models(&name).await { - 13502
Ok(models) => { - 13503
if name == "bedrock" { - 13504
match state.core.bedrock_model_availability(&models).await { - 13505
Ok(availability) => Json(serde_json::json!({ "provider": name, "models": models, "availability": availability })).into_response(), - 13506
Err(e) => Json(serde_json::json!({ "provider": name, "models": models, "availability_error": e.to_string() })).into_response(), - 13507
} - 13508
} else { - 13509
Json(serde_json::json!({ "provider": name, "models": models })).into_response() - 13510
} - 13511
} - 13512
Err(e) => { - 13513
let mut body = provider_error_body(&e); - 13514
body["provider"] = serde_json::Value::String(name); - 13515
(StatusCode::BAD_GATEWAY, Json(body)).into_response() - 13516
} - 13517
} - 13518
} - 13519
- 13520
async fn model_availability( - 13521
State(state): State<AppState>, - 13522
axum::extract::Path(name): axum::extract::Path<String>, - 13523
) -> axum::response::Response { - 13524
if name != "bedrock" { - 13525
return ( - 13526
StatusCode::NOT_FOUND, - 13527
Json(serde_json::json!({"error":"availability is only supported for bedrock"})), - 13528
) - 13529
.into_response(); - 13530
} - 13531
let models = match state.core.discover_models("bedrock").await { - 13532
Ok(models) => models, - 13533
Err(e) => { - 13534
let mut body = provider_error_body(&e); - 13535
body["provider"] = serde_json::Value::String(name); - 13536
return (StatusCode::BAD_GATEWAY, Json(body)).into_response(); - 13537
} - 13538
}; - 13539
match state.core.bedrock_model_availability(&models).await { - 13540
Ok(availability) => { - 13541
Json(serde_json::json!({"provider":name,"models":availability})).into_response() - 13542
} - 13543
Err(e) => ( - 13544
StatusCode::BAD_GATEWAY, - 13545
Json(serde_json::json!({"provider":name,"error":e.to_string()})), - 13546
) - 13547
.into_response(), - 13548
} - 13549
} - 13550
- 13551
/// Read provider-published account metadata without returning credentials. - 13552
async fn provider_status( - 13553
State(state): State<AppState>, - 13554
axum::extract::Path(name): axum::extract::Path<String>, - 13555
) -> axum::response::Response { - 13556
if !Core::provider_known(&name) { - 13557
return ( - 13558
StatusCode::NOT_FOUND, - 13559
Json(serde_json::json!({ "error": format!("unknown provider '{name}'") })), - 13560
) - 13561
.into_response(); - 13562
} - 13563
match state.core.provider_status(&name).await { - 13564
Ok(status) => Json(status).into_response(), - 13565
Err(e) => ( - 13566
StatusCode::BAD_GATEWAY, - 13567
Json(serde_json::json!({ "provider": name, "error": e.to_string() })), - 13568
) - 13569
.into_response(), - 13570
} - 13571
} - 13572
- 13573
#[derive(serde::Deserialize)] - 13574
struct ProviderKeyBody { - 13575
provider: String, - 13576
key: String, - 13577
#[serde(default)] - 13578
scope: Option<ConfigScope>, - 13579
} - 13580
- 13581
/// Persists a credential to the Shared secret scope and makes it - 13582
/// effective immediately. The key is accepted once and never echoed back. - 13583
async fn put_provider_key( - 13584
State(state): State<AppState>, - 13585
Json(body): Json<ProviderKeyBody>, - 13586
) -> axum::response::Response { - 13587
let scope = body.scope.unwrap_or(ConfigScope::User); - 13588
match state - 13589
.core - 13590
.set_provider_key_scoped(&body.provider, &body.key, scope.is_workspace()) - 13591
{ - 13592
Ok(env_var) => { - 13593
vak_core::security_events::record( - 13594
&state.core.sessions_home(), - 13595
vak_core::security_events::EventKind::ProviderKeyChange, - 13596
"provider_key_set", - 13597
&format!("provider={} scope={}", body.provider, scope.label()), - 13598
None, - 13599
); - 13600
state - 13601
.hub - 13602
.emit_config_changed("provider_key_set", &body.provider); - 13603
Json(serde_json::json!({ - 13604
"provider": body.provider, - 13605
"env_var": env_var, - 13606
"configured": true, - 13607
})) - 13608
.into_response() - 13609
} - 13610
Err(e) => ( - 13611
StatusCode::BAD_REQUEST, - 13612
Json(serde_json::json!({ "error": e.to_string() })), - 13613
) - 13614
.into_response(), - 13615
} - 13616
} - 13617
- 13618
// ---- Distributed event bus (vak-bus, docs/design/53) ------------------- - 13619
- 13620
#[derive(serde::Deserialize)] - 13621
struct BusConfigBody { - 13622
nats_url: Option<String>, - 13623
/// NATS credentials JWT. Stored in the workspace secret scope and read - 13624
/// on next server start. Never returned by GET. - 13625
#[serde(default)] - 13626
nats_credentials_jwt: Option<String>, - 13627
/// NATS nkey seed. Stored in the workspace secret scope and read - 13628
/// on next server start. Never returned by GET. - 13629
#[serde(default)] - 13630
nats_nkey_seed: Option<String>, - 13631
/// Name of the env var holding the workspace encryption secret. - 13632
#[serde(default)] - 13633
workspace_secret_env: Option<String>, - 13634
} - 13635
- 13636
/// GET /config/bus — bus status and configuration (non-secret fields only). - 13637
async fn get_bus_config(State(state): State<AppState>) -> axum::response::Response { - 13638
let cfg = state.core.config().server.bus.clone(); - 13639
let status = state.hub.bus_status(); - 13640
Json(serde_json::json!({ - 13641
"nats_url": cfg.nats_url, - 13642
"encrypted": cfg.workspace_secret.is_some(), - 13643
"runtime": status, - 13644
})) - 13645
.into_response() - 13646
} - 13647
- 13648
/// PUT /config/bus — sets the bus for this workspace and applies it now - 13649
/// (invariant 31). The NATS URL and the workspace-secret variable's name
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.