use std::path::Path;
use serde_json::{json, Value as JsonValue};
use super::{
ApprovalShape, EvaluationContext, PolicyAction, PolicyIdentityMatch, PolicyRule,
PolicyRuleMatch, PolicyRuleSource, ToolApprovalRequest,
};
pub(in crate::orchestration::policy) fn evaluate_dispatch(
policy: &super::ToolApprovalPolicy,
tool: &str,
args: &JsonValue,
repeat_count: Option<u64>,
annotations: Option<&crate::tool_annotations::ToolAnnotations>,
) -> (super::PolicyEvaluation, Option<String>) {
let context = EvaluationContext::new(tool, args, repeat_count, annotations);
let identity = context.invocation_sha256.clone();
let decision = match super::validate_tool_approval_path_arguments(tool, args, annotations) {
Ok(()) => super::evaluate_context(policy, context),
Err(reason) => super::host_request::invalid_context(&context, reason),
};
(decision, identity)
}
pub(super) fn digest(
context: &EvaluationContext,
arguments: &JsonValue,
workspace: &Path,
) -> Option<String> {
let root = workspace.canonicalize().ok()?;
if !root.is_dir() || !arguments.is_object() {
return None;
}
let mut paths = context
.path_entries
.iter()
.map(|path| crate::canonical_json::to_vec(&json!(path)))
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
Some(super::stable_json_digest(&json!({
"schema": "harn.remembered_invocation.v1",
"workspace": root,
"tool": context.tool_name,
"arguments": arguments,
"constraints": context.invocation_constraints(),
"paths": paths,
})))
}
impl ToolApprovalRequest {
pub fn capture_decision(&self, action: PolicyAction) -> Result<PolicyRule, String> {
self.validate()?;
if !matches!(action, PolicyAction::Allow | PolicyAction::Deny) {
return Err("remembered decisions must be allow or deny".into());
}
if self.workspace_boundary.is_none() {
return Err("remembered decisions require an explicit workspace_boundary".into());
}
let context = super::host_request::request_context(self);
let env_mode = context
.literal_identity
.as_ref()
.ok_or("remembered decision has no original invocation constraints")?
.constraints
.env_mode
.clone();
let invocation_sha256 = context
.invocation_sha256
.ok_or("remembered decision has no canonical invocation identity")?;
Ok(PolicyRule {
id: None,
action,
source: PolicyRuleSource::User,
identity_match: PolicyIdentityMatch::Literal,
matches: PolicyRuleMatch {
tool: vec![self.tool_name.clone()],
invocation_sha256: Some(invocation_sha256),
env_mode,
..Default::default()
},
reason: None,
approval: ApprovalShape::default(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orchestration::policy::ToolApprovalPolicy;
fn request(root: &Path, arguments: JsonValue) -> ToolApprovalRequest {
ToolApprovalRequest::from_host_json(json!({
"tool_name": "run", "arguments": arguments,
"workspace_boundary": {"root": root}
}))
.unwrap()
}
fn policy(rule: PolicyRule) -> ToolApprovalPolicy {
ToolApprovalPolicy::from_host_json(json!({"rules": [
{"source": "mode", "ask": "run"}, rule
]}))
.unwrap()
}
#[test]
fn remembered_decision_binds_all_arguments_and_workspace_without_persisting_them() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let original = request(
first.path(),
json!({
"command": "echo harmless", "paths": ["first", "second"],
"env": {"TOKEN": "secret-sentinel"}, "argv": ["a b", "c"]
}),
);
let rule = original.capture_decision(PolicyAction::Allow).unwrap();
let serialized = serde_json::to_string(&rule).unwrap();
assert!(!serialized.contains("secret-sentinel"));
assert!(!serialized.contains("echo harmless"));
let restored: PolicyRule = serde_json::from_str(&serialized).unwrap();
let saved = policy(restored);
assert!(saved.evaluate_request(&original).is_allow());
for replacement in [
json!({"command": "echo changed"}),
json!({"paths": ["first", "changed"]}),
json!({"env": {"TOKEN": "changed"}}),
json!({"argv": ["a", "b c"]}),
] {
let mut changed = original.clone();
for (key, value) in replacement.as_object().unwrap() {
changed.arguments[key] = value.clone();
}
changed.policy_decision = Some(json!({"context": {"rawInput": original.arguments}}));
assert!(saved.evaluate_request(&changed).is_ask(), "{replacement}");
}
let other_workspace = request(second.path(), original.arguments.clone());
assert!(saved.evaluate_request(&other_workspace).is_ask());
let mut no_boundary = original.clone();
no_boundary.workspace_boundary = None;
let refused = saved.evaluate_request(&no_boundary);
assert!(refused.is_deny());
assert_eq!(
refused.receipt["matched_rule"]["id"],
"invalid_host_request"
);
assert!(no_boundary.capture_decision(PolicyAction::Allow).is_err());
assert!(original.capture_decision(PolicyAction::Ask).is_err());
let denied = policy(original.capture_decision(PolicyAction::Deny).unwrap());
assert!(denied.evaluate_request(&original).is_deny());
assert!(denied.evaluate_request(&other_workspace).is_ask());
}
#[test]
fn remembered_decision_ignores_receipts_and_json_object_order() {
let root = tempfile::tempdir().unwrap();
let original = request(
root.path(),
json!({"command": "echo ok", "argv": ["a", "b"]}),
);
let mut same = request(
root.path(),
json!({"argv": ["a", "b"], "command": "echo ok"}),
);
same.repeat_count = Some(99);
same.policy_decision = Some(json!({"context": {"command": "invented", "agent": "old"}}));
let rule = original.capture_decision(PolicyAction::Allow).unwrap();
assert_eq!(
rule.matches,
same.capture_decision(PolicyAction::Allow).unwrap().matches
);
assert!(policy(rule).evaluate_request(&same).is_allow());
}
#[test]
fn remembered_write_environment_grants_cover_only_the_captured_mode() {
let root = tempfile::tempdir().unwrap();
for mode in ["patch", "replace"] {
let original = request(root.path(), json!({"command": "echo ok", "env_mode": mode}));
let saved = policy(original.capture_decision(PolicyAction::Allow).unwrap());
assert!(saved.evaluate_request(&original).is_allow(), "{mode}");
for stale_mode in ["inherit_clean", "patch", "replace"] {
let mut stale = original.clone();
stale.policy_decision = Some(json!({"context": {"env_mode": stale_mode}}));
assert_eq!(
original
.capture_decision(PolicyAction::Allow)
.unwrap()
.matches,
stale.capture_decision(PolicyAction::Allow).unwrap().matches
);
assert!(saved.evaluate_request(&stale).is_allow());
}
let mut changed = original.clone();
changed.arguments["env_mode"] =
json!(if mode == "patch" { "replace" } else { "patch" });
assert!(saved.evaluate_request(&changed).is_ask());
}
}
#[test]
fn rootless_path_free_evaluation_has_no_remembered_identity() {
let root = crate::orchestration::execution_root_path()
.canonicalize()
.unwrap();
let original = request(&root, json!({"command": "echo safe"}));
let saved = policy(original.capture_decision(PolicyAction::Allow).unwrap());
let rootless = ToolApprovalRequest::from_host_json(json!({
"tool_name": "run", "arguments": original.arguments
}))
.unwrap();
let context = super::super::host_request::request_context(&rootless);
assert!(context.invocation_sha256.is_none());
assert!(saved.evaluate_request(&rootless).is_ask());
assert!(rootless.capture_decision(PolicyAction::Allow).is_err());
}
#[test]
fn unannotated_path_projection_retains_workspace_refusal() {
let root = crate::orchestration::execution_root_path()
.canonicalize()
.unwrap();
let outside = tempfile::tempdir().unwrap();
let saved = ToolApprovalPolicy::from_host_json(json!({
"rules": [{"source": "mode", "allow": "run"}]
}))
.unwrap();
let original = request(&root, json!({"path": outside.path().join("outside.txt")}));
let result =
super::super::evaluate_tool_approval_policy(&saved, "run", &original.arguments, None);
assert!(result.is_deny(), "{result:?}");
assert!(saved.evaluate_request(&original).is_deny());
assert!(ToolApprovalRequest::from_host_json(json!({
"tool_name": "run", "arguments": {"path": 42},
"workspace_boundary": {"root": root}
}))
.is_err());
}
#[test]
fn host_capture_matches_vm_dispatch_without_tool_annotations() {
use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
let root = crate::orchestration::execution_root_path()
.canonicalize()
.unwrap();
for arguments in [
json!({"path": "reference.txt"}),
json!({"paths": ["b", root.join("z")]}),
json!({"source_path": "before.txt", "new_path": "after.txt"}),
json!({"command": "cat reference.txt"}),
] {
let original = request(&root, arguments);
push_execution_policy(CapabilityPolicy::default());
let saved = policy(original.capture_decision(PolicyAction::Allow).unwrap());
let host_result = saved.evaluate_request(&original);
let result = super::super::evaluate_tool_approval_policy(
&saved,
"run",
&original.arguments,
None,
);
let mut changed = original.arguments.clone();
changed["changed_invocation"] = json!(true);
let changed_result =
super::super::evaluate_tool_approval_policy(&saved, "run", &changed, None);
pop_execution_policy();
assert!(host_result.is_allow(), "{host_result:?}");
assert!(result.is_allow(), "{result:?}");
assert!(changed_result.is_ask(), "{changed_result:?}");
}
}
#[test]
fn host_capture_matches_vm_dispatch_with_mixed_path_order() {
use crate::orchestration::{pop_execution_policy, push_execution_policy, CapabilityPolicy};
let root = crate::orchestration::execution_root_path()
.canonicalize()
.unwrap();
let mut original = request(&root, json!({"paths": ["b", root.join("z")]}));
original.tool_annotations = Some(
serde_json::from_value(json!({
"kind": "read", "arg_schema": {"path_params": ["paths"]}
}))
.unwrap(),
);
let saved = policy(original.capture_decision(PolicyAction::Allow).unwrap());
assert!(saved.evaluate_request(&original).is_allow());
let mut ambient = CapabilityPolicy::default();
ambient
.tool_annotations
.insert("run".into(), original.tool_annotations.clone().unwrap());
push_execution_policy(ambient);
let result =
super::super::evaluate_tool_approval_policy(&saved, "run", &original.arguments, None);
pop_execution_policy();
assert!(result.is_allow(), "{result:?}");
}
}