use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::path::Path;
use super::{
evaluate_tool_approval_request, first_string, EvaluationContext, PolicyAction,
PolicyEvaluation, PolicyIdentityMatch, PolicyRule, ToolApprovalPolicy,
};
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolApprovalWorkspaceBoundary {
pub root: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolApprovalRequest {
pub tool_name: String,
pub arguments: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_decision: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_request: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repeat_count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_boundary: Option<ToolApprovalWorkspaceBoundary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_annotations: Option<crate::tool_annotations::ToolAnnotations>,
}
impl ToolApprovalRequest {
pub fn from_host_json(value: JsonValue) -> Result<Self, String> {
if !value.is_object() {
return Err("request must be an object".into());
}
let mut ignored = Vec::new();
let request: Self =
serde_ignored::deserialize(value, |path| ignored.push(path.to_string()))
.map_err(|error| error.to_string())?;
if !ignored.is_empty() {
return Err(format!("unknown request fields: {}", ignored.join(", ")));
}
request.validate()?;
Ok(request)
}
pub(super) fn validate(&self) -> Result<(), String> {
if self.tool_name.trim().is_empty() {
return Err("tool_name must be nonempty".into());
}
if !self.arguments.is_object() {
return Err("arguments must be an object".into());
}
if self
.policy_decision
.as_ref()
.is_some_and(|value| !value.is_object())
|| self
.approval_request
.as_ref()
.is_some_and(|value| !value.is_object())
{
return Err("policy_decision and approval_request must be objects when present".into());
}
let params = self.path_parameters();
super::path_inputs::validate(&self.arguments, ¶ms)?;
if self.workspace_boundary.is_none()
&& (params
.iter()
.any(|param| self.arguments.get(param).is_some())
|| !request_context(self).path_candidates.is_empty())
{
return Err("path-bearing requests require an explicit workspace_boundary".into());
}
if let Some(boundary) = &self.workspace_boundary {
if boundary.root.contains('\0')
|| !Path::new(&boundary.root).is_absolute()
|| !Path::new(&boundary.root).is_dir()
{
return Err(
"workspace_boundary.root must name an existing absolute directory".into(),
);
}
}
Ok(())
}
fn path_parameters(&self) -> Vec<String> {
let annotations = self
.tool_annotations
.clone()
.or_else(|| super::super::current_tool_annotations(&self.tool_name));
super::path_inputs::parameters(annotations.as_ref())
}
}
impl ToolApprovalPolicy {
pub fn from_host_json(value: JsonValue) -> Result<Self, String> {
if !value.is_object() {
return Err("policy must be an object".into());
}
let mut ignored = Vec::new();
let policy = serde_ignored::deserialize(value, |path| ignored.push(path.to_string()))
.map_err(|error| error.to_string())?;
if !ignored.is_empty() {
return Err(format!("unknown policy fields: {}", ignored.join(", ")));
}
Ok(policy)
}
pub fn evaluate_request(&self, request: &ToolApprovalRequest) -> PolicyEvaluation {
evaluate_tool_approval_request(self, request)
}
}
pub(super) fn invalid_request(request: &ToolApprovalRequest, reason: String) -> PolicyEvaluation {
let context = EvaluationContext::new(
&request.tool_name,
&request.arguments,
request.repeat_count,
request.tool_annotations.as_ref(),
);
invalid_context(&context, reason)
}
pub(super) fn invalid_context(context: &EvaluationContext, reason: String) -> PolicyEvaluation {
super::evaluation_from_candidate(
super::Candidate {
source: "invalid_host_request".into(),
source_rank: super::PolicyRuleSource::Policy,
index: None,
id: Some("invalid_host_request".into()),
action: PolicyAction::Deny,
reason,
approval: Default::default(),
risk_labels: vec!["invalid_host_request".into()],
denied_paths: Vec::new(),
contributing_rules: Vec::new(),
},
context,
)
}
pub(super) fn request_context(request: &ToolApprovalRequest) -> EvaluationContext {
let mut context = EvaluationContext::new(
&request.tool_name,
&request.arguments,
request.repeat_count,
request.tool_annotations.as_ref(),
);
let declared_params = request
.tool_annotations
.as_ref()
.map(|value| value.arg_schema.path_params.as_slice());
let owned_paths = request.workspace_boundary.as_ref().map(|boundary| {
let annotations = request
.tool_annotations
.clone()
.or_else(|| super::super::current_tool_annotations(&request.tool_name));
super::path_inputs::classify(
&request.arguments,
annotations.as_ref(),
Path::new(&boundary.root),
)
});
if let Some(annotations) = &request.tool_annotations {
set_owned_annotations(&mut context, annotations);
}
if let Some(paths) = &owned_paths {
set_owned_paths(&mut context, paths);
context.literal_identity = Some(super::LiteralResourceIdentity::capture(
&context,
&request.arguments,
declared_params,
));
}
context.invocation_sha256 = request.workspace_boundary.as_ref().and_then(|boundary| {
super::invocation_memory::digest(&context, &request.arguments, Path::new(&boundary.root))
});
context.absorb_host_value(&request.arguments);
let policy_context = request
.policy_decision
.as_ref()
.and_then(|decision| decision.get("context"))
.or_else(|| {
request
.approval_request
.as_ref()
.and_then(|approval| approval.get("undo_metadata"))
.and_then(|metadata| metadata.get("policy_decision"))
.and_then(|decision| decision.get("context"))
});
let nested_policy_context = policy_context.and_then(|context| context.get("policy_context"));
if let Some(policy_context) = policy_context {
context.tool_kind =
first_string(policy_context, &["tool_kind", "toolKind"]).or(context.tool_kind);
context.side_effect = first_string(
policy_context,
&[
"side_effect",
"sideEffect",
"requested_side_effect_level",
"requestedSideEffectLevel",
],
)
.or(context.side_effect);
context.agent = first_string(policy_context, &["agent", "agent_id"]).or(context.agent);
context.persona =
first_string(policy_context, &["persona", "persona_id"]).or(context.persona);
context.mode = first_string(policy_context, &["mode", "action"]).or(context.mode);
context.absorb_host_value(policy_context);
}
if let Some(nested_policy_context) = nested_policy_context {
if context.side_effect.is_none() {
context.side_effect = first_string(
nested_policy_context,
&[
"side_effect",
"sideEffect",
"requested_side_effect_level",
"requestedSideEffectLevel",
],
);
}
context.absorb_host_value(nested_policy_context);
}
for container in [policy_context, Some(&request.arguments)]
.into_iter()
.flatten()
{
for key in ["rawInput", "raw_input", "input"] {
if let Some(input) = container.get(key) {
context.absorb_host_value(input);
}
}
}
context.finish_host_normalization();
if let Some(paths) = &owned_paths {
set_owned_paths(&mut context, paths);
}
if let Some(annotations) = &request.tool_annotations {
set_owned_annotations(&mut context, annotations);
}
context
}
fn set_owned_annotations(
context: &mut EvaluationContext,
annotations: &crate::tool_annotations::ToolAnnotations,
) {
context.tool_kind = Some(super::tool_kind_string(annotations.kind).to_string());
context.side_effect = Some(annotations.side_effect_level.as_str().to_string());
context.capabilities = annotations
.capabilities
.iter()
.flat_map(|(capability, operations)| {
operations
.iter()
.map(move |operation| format!("{capability}.{operation}"))
})
.collect();
}
fn set_owned_paths(
context: &mut EvaluationContext,
paths: &[crate::workspace_path::WorkspacePathInfo],
) {
context.path_entries = paths.to_vec();
context.path_candidates = paths
.iter()
.flat_map(|path| path.policy_candidates())
.collect();
super::dedup(&mut context.path_candidates);
}
fn normalized_env_modes(env_modes: &[String]) -> Vec<String> {
if env_modes.is_empty() {
vec!["inherit_clean".to_string()]
} else {
env_modes.to_vec()
}
}
pub(super) fn env_modes_match(
patterns: &[String],
env_modes: &[String],
identity: PolicyIdentityMatch,
) -> bool {
patterns.is_empty()
|| normalized_env_modes(env_modes)
.iter()
.all(|mode| identity.matches(patterns, std::slice::from_ref(mode)))
}
pub(super) fn exact_write_env_allow(rule: &PolicyRule, ctx: &EvaluationContext) -> bool {
if rule.action != PolicyAction::Allow {
return true;
}
let modes = if rule.identity_match == PolicyIdentityMatch::Literal {
let Some(original) = &ctx.literal_identity else {
return false;
};
&original.constraints.env_mode
} else {
&ctx.env_modes
};
normalized_env_modes(modes)
.iter()
.filter(|mode| matches!(mode.as_str(), "patch" | "replace"))
.all(|mode| rule.matches.env_mode.contains(mode))
}
#[cfg(test)]
mod decoder_tests {
use super::*;
use serde_json::json;
#[test]
fn host_policy_rejects_matchers_that_shorthand_would_discard() {
for alias in ["match", "matches", "when"] {
for action in ["allow", "ask", "deny", "require_approval"] {
for field in ["command", "commmand"] {
let rule = json!({"source": "user", (action): "run",
(alias): {(field): "git push"}});
let error =
ToolApprovalPolicy::from_host_json(json!({"rules": [rule]})).unwrap_err();
assert!(
error.contains("nested matcher"),
"{alias}/{action}/{field}: {error}"
);
}
}
}
}
#[test]
fn host_policy_rejects_unknown_nested_matchers_without_widening_rules() {
for key in [
"allow",
"ask",
"deny",
"require_approval",
"match",
"matches",
"when",
] {
let mut rule = json!({(key): {"tool": "run", "commmand": "git push"}});
if ["match", "matches", "when"].contains(&key) {
rule["action"] = json!("allow");
}
let error = ToolApprovalPolicy::from_host_json(json!({"rules": [rule]})).unwrap_err();
assert!(error.contains("commmand"), "{key}: {error}");
}
assert!(ToolApprovalPolicy::from_host_json(json!({"rules": [
{"action": "allow", "tool": "run", "commmand": "git push"}
]}))
.is_err());
assert!(ToolApprovalPolicy::from_host_json(json!({"rules": [
{"allow": "run", "approval": {"grant_option": ["once"]}}
]}))
.is_err());
let valid = ToolApprovalPolicy::from_host_json(json!({"rules": [
{"source": "mode", "ask": "run"},
{"source": "user", "allow": {"tools": "run", "commands": "git push"},
"approval": {"grant_options": ["once"], "metadata": {"custom": "retained"}}}
]}))
.unwrap();
let request = ToolApprovalRequest::from_host_json(json!({
"tool_name": "run", "arguments": {"command": "printf unexpected"}
}))
.unwrap();
assert!(valid.evaluate_request(&request).is_ask());
assert_eq!(
valid.rules[1].approval.metadata,
Some(json!({"custom": "retained"}))
);
}
}