1use std::collections::BTreeSet;
2use std::sync::Arc;
3
4use uuid::Uuid;
5
6use crate::error::RuntimeError;
7use crate::permission::{
8 ApprovalTarget, BatchMode, DecisionAuthority, GrantScope, GroupOwner, PermissionAction,
9 PermissionBroker, PermissionGroup, PermissionGroupId, PermissionRequest, PermissionRequestId,
10 PermissionSelector, ResolveOutcome,
11};
12use crate::tool::{BoxFut, InvocationPlane, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
13use crate::trust::RiskKind;
14use crate::value::Value;
15
16pub struct PermissionList;
17pub struct PermissionGet;
18pub struct PermissionGroupTool;
19pub struct PermissionUngroup;
20pub struct PermissionApprove;
21pub struct PermissionDeny;
22pub struct PermissionDefer;
23pub struct PermissionBatch;
24
25#[cfg(test)]
26pub(crate) const PERMISSION_TOOL_NAMES: &[&str] = &[
27 "permission.list",
28 "permission.get",
29 "permission.group",
30 "permission.ungroup",
31 "permission.approve",
32 "permission.deny",
33 "permission.defer",
34 "permission.batch",
35];
36
37fn broker_and_actor<'a>(
38 ctx: &'a ToolCtx,
39 name: &str,
40) -> Result<
41 (
42 &'a Arc<PermissionBroker>,
43 &'a Arc<crate::flow_authority::FlowIdentity>,
44 ),
45 RuntimeError,
46> {
47 let broker = ctx
48 .permission_broker
49 .as_ref()
50 .ok_or_else(|| failed(name, "permission broker is missing"))?;
51 let actor = ctx
52 .flow_identity
53 .as_ref()
54 .ok_or_else(|| failed(name, "permission identity is missing"))?;
55 broker
56 .authenticate_control_actor(actor)
57 .map_err(|error| failed(name, error))?;
58 Ok((broker, actor))
59}
60
61fn failed(name: &str, error: impl std::fmt::Display) -> RuntimeError {
62 RuntimeError::ToolFailed(format!("{name}: {error}"))
63}
64
65fn string_arg(args: &ToolArgs, name: &str, position: usize) -> Result<String, RuntimeError> {
66 let value = args.named(name).or_else(|| args.positional.get(position));
67 match value {
68 Some(Value::Str(value)) => Ok(value.clone()),
69 Some(other) => Err(RuntimeError::TypeMismatch {
70 expected: "string".into(),
71 actual: other.kind_name().into(),
72 }),
73 None => Err(RuntimeError::MissingArg(name.into())),
74 }
75}
76
77fn optional_string(args: &ToolArgs, name: &str) -> Result<Option<String>, RuntimeError> {
78 match args.named(name) {
79 Some(Value::Str(value)) => Ok(Some(value.clone())),
80 Some(Value::Unit) | None => Ok(None),
81 Some(other) => Err(RuntimeError::TypeMismatch {
82 expected: "string".into(),
83 actual: other.kind_name().into(),
84 }),
85 }
86}
87
88fn request_id(args: &ToolArgs) -> Result<PermissionRequestId, RuntimeError> {
89 parse_request_id(&string_arg(args, "request_id", 0)?)
90}
91
92fn parse_request_id(value: &str) -> Result<PermissionRequestId, RuntimeError> {
93 Uuid::parse_str(value)
94 .map(PermissionRequestId)
95 .map_err(|_| failed("permission", "request_id is not a valid UUID"))
96}
97
98fn group_id(args: &ToolArgs) -> Result<PermissionGroupId, RuntimeError> {
99 Uuid::parse_str(&string_arg(args, "group_id", 0)?)
100 .map(PermissionGroupId)
101 .map_err(|_| failed("permission", "group_id is not a valid UUID"))
102}
103
104fn request_ids(
105 args: &ToolArgs,
106 required: bool,
107) -> Result<BTreeSet<PermissionRequestId>, RuntimeError> {
108 let Some(value) = args
109 .named("request_ids")
110 .or_else(|| args.positional.first())
111 else {
112 return if required {
113 Err(RuntimeError::MissingArg("request_ids".into()))
114 } else {
115 Ok(BTreeSet::new())
116 };
117 };
118 let Value::List(values) = value else {
119 return Err(RuntimeError::TypeMismatch {
120 expected: "list".into(),
121 actual: value.kind_name().into(),
122 });
123 };
124 values
125 .iter()
126 .map(|value| match value {
127 Value::Str(value) => parse_request_id(value),
128 other => Err(RuntimeError::TypeMismatch {
129 expected: "string".into(),
130 actual: other.kind_name().into(),
131 }),
132 })
133 .collect()
134}
135
136fn request_value(request: PermissionRequest) -> Value {
137 let (state, target) = match request.state {
138 crate::permission::PermissionRequestState::Evaluating => ("evaluating", Value::Unit),
139 crate::permission::PermissionRequestState::Pending { target } => {
140 let target = match target {
141 crate::permission::ApprovalTarget::Flow(run_id) => run_id.to_string(),
142 crate::permission::ApprovalTarget::User => "user".into(),
143 };
144 ("pending", Value::Str(target))
145 }
146 crate::permission::PermissionRequestState::Approved { .. } => ("approved", Value::Unit),
147 crate::permission::PermissionRequestState::Denied { .. } => ("denied", Value::Unit),
148 crate::permission::PermissionRequestState::Cancelled { .. } => ("cancelled", Value::Unit),
149 };
150 Value::Struct(vec![
151 (
152 "request_id".into(),
153 Value::Str(request.request_id.to_string()),
154 ),
155 (
156 "requesting_run_id".into(),
157 Value::Str(request.requesting_run_id.to_string()),
158 ),
159 ("tool_use_id".into(), Value::Str(request.intent.tool_use_id)),
160 ("tool_name".into(), Value::Str(request.intent.tool_name)),
161 (
162 "tier".into(),
163 Value::Str(format!("{:?}", request.intent.tier).to_lowercase()),
164 ),
165 ("state".into(), Value::Str(state.into())),
166 ("target".into(), target),
167 (
168 "requested_at".into(),
169 Value::Str(request.requested_at.to_rfc3339()),
170 ),
171 ])
172}
173
174fn group_value(group: PermissionGroup) -> Value {
175 let owner = match group.owner {
176 GroupOwner::Flow(run_id) => run_id.to_string(),
177 GroupOwner::User => "user".into(),
178 GroupOwner::System => "system".into(),
179 };
180 Value::Struct(vec![
181 ("group_id".into(), Value::Str(group.group_id.to_string())),
182 ("owner".into(), Value::Str(owner)),
183 ("label".into(), Value::Str(group.label)),
184 (
185 "request_ids".into(),
186 Value::List(
187 group
188 .request_ids
189 .into_iter()
190 .map(|id| Value::Str(id.to_string()))
191 .collect(),
192 ),
193 ),
194 (
195 "created_at".into(),
196 Value::Str(group.created_at.to_rfc3339()),
197 ),
198 ("revision".into(), Value::Int(group.revision as i64)),
199 ])
200}
201
202fn decision_value(outcome: ResolveOutcome) -> Value {
203 let decision = match outcome {
204 ResolveOutcome::Resolved(decision) | ResolveOutcome::Deferred(decision) => decision,
205 };
206 Value::Struct(vec![
207 (
208 "decision_id".into(),
209 Value::Str(decision.decision_id.to_string()),
210 ),
211 (
212 "request_id".into(),
213 Value::Str(decision.request_id.to_string()),
214 ),
215 (
216 "action".into(),
217 Value::Str(format!("{:?}", decision.action).to_lowercase()),
218 ),
219 ])
220}
221
222fn control_tool_defaults() -> (Tier, InvocationPlane) {
223 (Tier::Zero, InvocationPlane::PermissionControl)
224}
225
226fn batch_value(result: crate::permission::BatchResolution) -> Value {
227 let (status, decision) = match result.outcome {
228 crate::permission::BatchRequestOutcome::Approved(decision) => ("approved", Some(decision)),
229 crate::permission::BatchRequestOutcome::Denied(decision) => ("denied", Some(decision)),
230 crate::permission::BatchRequestOutcome::Deferred(decision) => ("deferred", Some(decision)),
231 crate::permission::BatchRequestOutcome::SkippedAlreadyResolved => {
232 ("skipped_already_resolved", None)
233 }
234 crate::permission::BatchRequestOutcome::RejectedNotAncestor => {
235 ("rejected_not_ancestor", None)
236 }
237 crate::permission::BatchRequestOutcome::RejectedOverAuthority => {
238 ("rejected_over_authority", None)
239 }
240 crate::permission::BatchRequestOutcome::RejectedStale => ("rejected_stale", None),
241 crate::permission::BatchRequestOutcome::RejectedNotFound => ("rejected_not_found", None),
242 crate::permission::BatchRequestOutcome::RejectedNotRunning => {
243 ("rejected_not_running", None)
244 }
245 crate::permission::BatchRequestOutcome::RejectedPermissionManagementRequired => {
246 ("rejected_permission_management_required", None)
247 }
248 crate::permission::BatchRequestOutcome::RejectedUnsupportedGrantScope => {
249 ("rejected_unsupported_grant_scope", None)
250 }
251 crate::permission::BatchRequestOutcome::RejectedNoEscalationTarget => {
252 ("rejected_no_escalation_target", None)
253 }
254 crate::permission::BatchRequestOutcome::Rejected(_) => ("rejected", None),
255 };
256 let mut fields = vec![
257 (
258 "request_id".into(),
259 Value::Str(result.request_id.to_string()),
260 ),
261 ("status".into(), Value::Str(status.into())),
262 ];
263 if let Some(decision) = decision {
264 fields.push((
265 "decision_id".into(),
266 Value::Str(decision.decision_id.to_string()),
267 ));
268 }
269 Value::Struct(fields)
270}
271
272impl Tool for PermissionBatch {
273 fn name(&self) -> &str {
274 "permission.batch"
275 }
276 fn tier(&self) -> Tier {
277 control_tool_defaults().0
278 }
279 fn invocation_plane(&self) -> InvocationPlane {
280 control_tool_defaults().1
281 }
282 fn description(&self) -> Option<&str> {
283 Some(
284 "Apply one action to permission requests. When several targets are supplied, the \
285 priority is request_ids, group_id, descendant_run_id, then selector.",
286 )
287 }
288 fn input_schema(&self) -> serde_json::Value {
289 serde_json::json!({
290 "type":"object",
291 "properties":{
292 "request_ids":{"type":"array","items":{"type":"string"}},
293 "group_id":{"type":"string"},
294 "descendant_run_id":{"type":"string"},
295 "selector":{"type":"string","enum":["child","tool","tier","risk","path","target"]},
296 "selector_value":{"type":"string"},
297 "action":{"type":"string","enum":["approve","deny","defer"]},
298 "atomic":{"type":"boolean"},
299 "group_revision":{"type":"integer","minimum":0},
300 "reason":{"type":"string"}
301 },
302 "required":["action"],
303 "additionalProperties":false
304 })
305 }
306 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
307 Box::pin(async move {
308 let (broker, actor) = broker_and_actor(ctx, self.name())?;
309 for field in [
310 "session_id",
311 "actor",
312 "actor_run_id",
313 "run_id",
314 "requester_run_id",
315 ] {
316 if args.named(field).is_some() {
317 return Err(failed(
318 self.name(),
319 format!("caller-supplied {field} is not allowed"),
320 ));
321 }
322 }
323 if args.named("request_ids").is_none()
324 && args.named("group_id").is_none()
325 && args.named("descendant_run_id").is_none()
326 && args.named("selector").is_none()
327 {
328 return Err(failed(
329 self.name(),
330 "one of request_ids, group_id, descendant_run_id, or selector is required",
331 ));
332 }
333 let selector = if args.named("request_ids").is_some() {
334 PermissionSelector::RequestIds(request_ids(&args, true)?.into_iter().collect())
335 } else if args.named("group_id").is_some() {
336 PermissionSelector::Group(group_id(&args)?)
337 } else if args.named("descendant_run_id").is_some() {
338 let value = string_arg(&args, "descendant_run_id", 0)?;
339 PermissionSelector::DescendantRun(crate::event::FlowRunId(
340 Uuid::parse_str(&value).map_err(|_| {
341 failed(self.name(), "descendant_run_id is not a valid UUID")
342 })?,
343 ))
344 } else {
345 let kind = string_arg(&args, "selector", 0)?;
346 let value = optional_string(&args, "selector_value")?;
347 match kind.as_str() {
348 "child" if value.is_none() => PermissionSelector::ChildRun,
349 "tool" => PermissionSelector::Tool(value.ok_or_else(|| {
350 failed(self.name(), "tool selector requires selector_value")
351 })?),
352 "tier" => PermissionSelector::Tier(match value.as_deref() {
353 Some("zero") | Some("0") => Tier::Zero,
354 Some("one") | Some("1") => Tier::One,
355 Some("two") | Some("2") => Tier::Two,
356 Some("three") | Some("3") => Tier::Three,
357 Some("four") | Some("4") => Tier::Four,
358 _ => {
359 return Err(failed(
360 self.name(),
361 "tier selector_value must be zero through four",
362 ));
363 }
364 }),
365 "risk" => PermissionSelector::Risk(match value.as_deref() {
366 Some("workspace_external") => RiskKind::WorkspaceExternal,
367 Some("network") => RiskKind::Network,
368 Some("irreversible") => RiskKind::Irreversible,
369 Some("filesystem_write") => RiskKind::FilesystemWrite,
370 Some("process_spawn") => RiskKind::ProcessSpawn,
371 Some("repository_mutation") => RiskKind::RepositoryMutation,
372 _ => return Err(failed(self.name(), "unknown risk selector_value")),
373 }),
374 "path" => {
375 PermissionSelector::PathPrefix(crate::fs_access::canonicalize_stable(
376 std::path::Path::new(&value.ok_or_else(|| {
377 failed(self.name(), "path selector requires selector_value")
378 })?),
379 ))
380 }
381 "target" => PermissionSelector::Target(match value.as_deref() {
382 Some("flow") => ApprovalTarget::Flow(actor.run_id.clone()),
383 _ => {
384 return Err(failed(self.name(), "target selector_value must be flow"));
385 }
386 }),
387 "child" => {
388 return Err(failed(
389 self.name(),
390 "child selector does not accept selector_value",
391 ));
392 }
393 _ => return Err(failed(self.name(), "unknown selector")),
394 }
395 };
396 let action = match string_arg(&args, "action", 1)?.as_str() {
397 "approve" => PermissionAction::Approve,
398 "deny" => PermissionAction::Deny,
399 "defer" => PermissionAction::Defer,
400 _ => {
401 return Err(failed(
402 self.name(),
403 "action must be approve, deny, or defer",
404 ));
405 }
406 };
407 let mode = match args.named("atomic") {
408 Some(Value::Bool(true)) => BatchMode::Atomic,
409 Some(Value::Bool(false)) | None => BatchMode::BestEffort,
410 Some(other) => {
411 return Err(RuntimeError::TypeMismatch {
412 expected: "bool".into(),
413 actual: other.kind_name().into(),
414 });
415 }
416 };
417 let revision = match args.named("group_revision") {
418 Some(Value::Int(value)) if *value >= 0 => Some(*value as u64),
419 Some(Value::Int(_)) => {
420 return Err(failed(self.name(), "group_revision must be non-negative"));
421 }
422 None => None,
423 Some(other) => {
424 return Err(RuntimeError::TypeMismatch {
425 expected: "int".into(),
426 actual: other.kind_name().into(),
427 });
428 }
429 };
430 if revision.is_some() && !matches!(selector, PermissionSelector::Group(_)) {
431 return Err(failed(
432 self.name(),
433 "group_revision requires the group_id selector",
434 ));
435 }
436 let results = broker
437 .resolve_batch(
438 actor,
439 selector,
440 action,
441 None,
442 optional_string(&args, "reason")?,
443 mode,
444 revision,
445 )
446 .map_err(|e| failed(self.name(), e))?;
447 Ok(Value::List(results.into_iter().map(batch_value).collect()))
448 })
449 }
450}
451
452impl Tool for PermissionList {
453 fn name(&self) -> &str {
454 "permission.list"
455 }
456 fn tier(&self) -> Tier {
457 control_tool_defaults().0
458 }
459 fn invocation_plane(&self) -> InvocationPlane {
460 control_tool_defaults().1
461 }
462 fn description(&self) -> Option<&str> {
463 Some("List permission requests currently targeted to this flow, plus its owned groups.")
464 }
465 fn input_schema(&self) -> serde_json::Value {
466 serde_json::json!({"type":"object","properties":{}})
467 }
468 fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
469 Box::pin(async move {
470 let (broker, actor) = broker_and_actor(ctx, self.name())?;
471 let requests = broker
472 .visible_list(actor)
473 .map_err(|e| failed(self.name(), e))?;
474 let groups = broker
475 .visible_group_list(actor)
476 .map_err(|e| failed(self.name(), e))?;
477 Ok(Value::Struct(vec![
478 (
479 "requests".into(),
480 Value::List(requests.into_iter().map(request_value).collect()),
481 ),
482 (
483 "groups".into(),
484 Value::List(groups.into_iter().map(group_value).collect()),
485 ),
486 ]))
487 })
488 }
489}
490
491impl Tool for PermissionGet {
492 fn name(&self) -> &str {
493 "permission.get"
494 }
495 fn tier(&self) -> Tier {
496 control_tool_defaults().0
497 }
498 fn invocation_plane(&self) -> InvocationPlane {
499 control_tool_defaults().1
500 }
501 fn description(&self) -> Option<&str> {
502 Some(
503 "Inspect one visible permission request or one group owned by this flow. When both \
504 IDs are supplied, request_id takes precedence.",
505 )
506 }
507 fn input_schema(&self) -> serde_json::Value {
508 serde_json::json!({"type":"object","properties":{
509 "request_id":{"type":"string"},
510 "group_id":{"type":"string"}
511 }})
512 }
513 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
514 Box::pin(async move {
515 let (broker, actor) = broker_and_actor(ctx, self.name())?;
516 if args.named("request_id").is_some() {
517 let value = args.named("request_id").expect("checked above");
518 let Value::Str(value) = value else {
519 return Err(RuntimeError::TypeMismatch {
520 expected: "string".into(),
521 actual: value.kind_name().into(),
522 });
523 };
524 let id = parse_request_id(value)?;
525 return broker
526 .visible_get(actor, &id)
527 .map_err(|e| failed(self.name(), e))?
528 .map(request_value)
529 .ok_or_else(|| {
530 failed(
531 self.name(),
532 "permission request was not found or is not visible",
533 )
534 });
535 }
536 let id = group_id(&args)?;
537 broker
538 .visible_group_get(actor, &id)
539 .map_err(|e| failed(self.name(), e))?
540 .map(group_value)
541 .ok_or_else(|| {
542 failed(
543 self.name(),
544 "permission group was not found or is not visible",
545 )
546 })
547 })
548 }
549}
550
551impl Tool for PermissionGroupTool {
552 fn name(&self) -> &str {
553 "permission.group"
554 }
555 fn tier(&self) -> Tier {
556 control_tool_defaults().0
557 }
558 fn invocation_plane(&self) -> InvocationPlane {
559 control_tool_defaults().1
560 }
561 fn description(&self) -> Option<&str> {
562 Some("Create an owned group from explicit visible permission request IDs.")
563 }
564 fn input_schema(&self) -> serde_json::Value {
565 serde_json::json!({"type":"object","properties":{
566 "request_ids":{"type":"array","items":{"type":"string"},"minItems":1},
567 "label":{"type":"string"}
568 },"required":["request_ids","label"]})
569 }
570 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
571 Box::pin(async move {
572 let (broker, actor) = broker_and_actor(ctx, self.name())?;
573 let ids = request_ids(&args, true)?;
574 let label = string_arg(&args, "label", 1)?;
575 broker
576 .create_group(actor, ids, label)
577 .map(group_value)
578 .map_err(|e| failed(self.name(), e))
579 })
580 }
581}
582
583impl Tool for PermissionUngroup {
584 fn name(&self) -> &str {
585 "permission.ungroup"
586 }
587 fn tier(&self) -> Tier {
588 control_tool_defaults().0
589 }
590 fn invocation_plane(&self) -> InvocationPlane {
591 control_tool_defaults().1
592 }
593 fn description(&self) -> Option<&str> {
594 Some("Remove explicit members from an owned group, or delete it once empty.")
595 }
596 fn input_schema(&self) -> serde_json::Value {
597 serde_json::json!({"type":"object","properties":{
598 "group_id":{"type":"string"},
599 "request_ids":{"type":"array","items":{"type":"string"}}
600 },"required":["group_id"]})
601 }
602 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
603 Box::pin(async move {
604 let (broker, actor) = broker_and_actor(ctx, self.name())?;
605 let id = group_id(&args)?;
606 if args.named("request_ids").is_none() {
607 return broker
608 .delete_empty_group(actor, &id)
609 .map(group_value)
610 .map_err(|e| failed(self.name(), e));
611 }
612 let ids = request_ids(&args, false)?;
613 broker
614 .ungroup_requests(actor, &id, &ids)
615 .map(group_value)
616 .map_err(|e| failed(self.name(), e))
617 })
618 }
619}
620
621fn approve_scope(
622 args: &ToolArgs,
623 request: &PermissionRequest,
624) -> Result<Option<GrantScope>, RuntimeError> {
625 let Some(scope) = optional_string(args, "scope")? else {
626 return Ok(None);
627 };
628 match scope.as_str() {
629 "current_call" => Ok(Some(GrantScope::CurrentCall)),
630 "child_run_same_tool" => Ok(Some(GrantScope::ChildRunSameTool {
631 run_id: request.requesting_run_id.clone(),
632 tool_name: request.intent.tool_name.clone(),
633 })),
634 "child_run_same_path_rule" => {
635 let provenance = &request.intent.provenance;
636 let root = provenance.workspace_root.as_deref().ok_or_else(|| {
637 failed(
638 "permission.approve",
639 "request has no structured workspace-relative path",
640 )
641 })?;
642 if provenance.authorized_targets().count() != 1 {
643 return Err(failed(
644 "permission.approve",
645 "request must have exactly one structured path target",
646 ));
647 }
648 let path = provenance
649 .authorized_targets()
650 .next()
651 .and_then(|path| path.strip_prefix(root).ok())
652 .and_then(|path| path.to_str())
653 .filter(|path| !path.is_empty())
654 .ok_or_else(|| {
655 failed(
656 "permission.approve",
657 "request has no structured workspace-relative path",
658 )
659 })?
660 .to_string();
661 Ok(Some(GrantScope::ChildRunSamePathRule {
662 run_id: request.requesting_run_id.clone(),
663 tool_name: request.intent.tool_name.clone(),
664 workspace_relative_path: path,
665 }))
666 }
667 _ => Err(failed(
668 "permission.approve",
669 "scope must be current_call, child_run_same_tool, or child_run_same_path_rule",
670 )),
671 }
672}
673
674fn resolve(
675 ctx: &ToolCtx,
676 name: &str,
677 args: &ToolArgs,
678 action: PermissionAction,
679) -> Result<Value, RuntimeError> {
680 let (broker, actor) = broker_and_actor(ctx, name)?;
681 let id = request_id(args)?;
682 let authority = DecisionAuthority::Flow(
683 broker
684 .flow_authority(Arc::clone(actor))
685 .map_err(|e| failed(name, e))?,
686 );
687 let request = broker
688 .visible_get(actor, &id)
689 .map_err(|e| failed(name, e))?
690 .ok_or_else(|| failed(name, "permission request was not found or is not visible"))?;
691 let scope = if action == PermissionAction::Approve {
692 approve_scope(args, &request)?
693 } else {
694 None
695 };
696 let reason = if action == PermissionAction::Deny {
697 Some(string_arg(args, "reason", 1)?)
698 } else {
699 optional_string(args, "reason")?
700 };
701 broker
702 .resolve(&id, &authority, action, scope, reason)
703 .map(decision_value)
704 .map_err(|e| failed(name, e))
705}
706
707macro_rules! decision_tool {
708 ($ty:ty, $name:literal, $action:expr, $schema:expr) => {
709 impl Tool for $ty {
710 fn name(&self) -> &str {
711 $name
712 }
713 fn tier(&self) -> Tier {
714 control_tool_defaults().0
715 }
716 fn invocation_plane(&self) -> InvocationPlane {
717 control_tool_defaults().1
718 }
719 fn input_schema(&self) -> serde_json::Value {
720 $schema
721 }
722 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
723 Box::pin(async move { resolve(ctx, self.name(), &args, $action) })
724 }
725 }
726 };
727}
728
729decision_tool!(
730 PermissionApprove,
731 "permission.approve",
732 PermissionAction::Approve,
733 serde_json::json!({"type":"object","properties":{
734 "request_id":{"type":"string"},
735 "scope":{"type":"string","enum":["current_call","child_run_same_tool","child_run_same_path_rule"]},
736 "reason":{"type":"string"}
737 },"required":["request_id"]})
738);
739decision_tool!(
740 PermissionDeny,
741 "permission.deny",
742 PermissionAction::Deny,
743 serde_json::json!({"type":"object","properties":{
744 "request_id":{"type":"string"},"reason":{"type":"string"}
745 },"required":["request_id","reason"]})
746);
747decision_tool!(
748 PermissionDefer,
749 "permission.defer",
750 PermissionAction::Defer,
751 serde_json::json!({"type":"object","properties":{
752 "request_id":{"type":"string"},"reason":{"type":"string"}
753 },"required":["request_id"]})
754);
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use crate::flow_authority::{ChildWorkspaceAuthority, EffectiveAuthority, InvocationKind};
760 use crate::permission::{
761 ApprovalTarget, PermissionIntent, PermissionRequestState, SubmissionOutcome,
762 };
763 use crate::tools::agent_ctrl::FlowRegistry;
764 use crate::trust::{TrustConfig, TrustMode};
765
766 fn managed_ctx() -> (
767 ToolCtx,
768 Arc<PermissionBroker>,
769 Arc<crate::flow_authority::FlowIdentity>,
770 ) {
771 let flows = Arc::new(FlowRegistry::new());
772 let trust = TrustConfig {
773 mode: TrustMode::Steady,
774 ..TrustConfig::default()
775 };
776 let root = flows
777 .register_root(
778 "permission-tools".into(),
779 crate::event::FlowRunId::now(),
780 EffectiveAuthority::root(&trust, true, None),
781 )
782 .unwrap();
783 let requester = flows
784 .register_child(
785 &root.run_id,
786 crate::event::FlowRunId::now(),
787 InvocationKind::InlineSubflow,
788 true,
789 ChildWorkspaceAuthority::Inherit,
790 )
791 .unwrap();
792 let broker = PermissionBroker::shared(Arc::clone(&flows));
793 let mut ctx = ToolCtx::new()
794 .with_flow_registry(flows)
795 .with_permission_broker(Arc::clone(&broker))
796 .with_trust(trust)
797 .with_anchors(None, Some(root.run_id.clone()), None);
798 ctx.flow_identity = Some(Arc::clone(&root));
799 (ctx, broker, requester)
800 }
801
802 fn submit_pending(
803 broker: &PermissionBroker,
804 requester: &crate::flow_authority::FlowIdentity,
805 ) -> crate::permission::PendingPermission {
806 let trust = TrustConfig {
807 mode: TrustMode::Steady,
808 ..TrustConfig::default()
809 };
810 let SubmissionOutcome::Pending(pending) = broker
811 .submit(
812 Some(&requester.session_id),
813 Some(&requester.run_id),
814 PermissionIntent::minimal("tool-use", "fs.write", Tier::Two),
815 false,
816 &trust,
817 )
818 .unwrap()
819 else {
820 panic!("expected pending request");
821 };
822 *pending
823 }
824
825 #[test]
826 fn all_permission_tools_are_registered_on_the_control_plane() {
827 let registry = crate::tool::ToolRegistry::new();
828 crate::tools::register_tier_zero(®istry);
829 for &name in PERMISSION_TOOL_NAMES {
830 let tool = registry
831 .get(name)
832 .unwrap_or_else(|| panic!("missing {name}"));
833 assert_eq!(tool.invocation_plane(), InvocationPlane::PermissionControl);
834 assert_eq!(tool.tier(), Tier::Zero);
835 assert_eq!(tool.input_schema()["type"], "object");
836 }
837 }
838
839 #[tokio::test]
840 async fn control_gate_fails_closed_without_broker_or_identity() {
841 let ctx = ToolCtx::new();
842 let before = ctx
843 .permission_broker
844 .as_ref()
845 .map(|broker| broker.list().len());
846 let result = crate::approval::authorize_tool_invocation(
847 &ctx,
848 "control-call",
849 "permission.list",
850 &ToolArgs::default(),
851 &PermissionList,
852 )
853 .await;
854 assert!(result.is_err());
855 assert_eq!(before, None);
856 }
857
858 #[tokio::test]
859 async fn list_and_approve_use_authenticated_ctx_without_recursive_request() {
860 let (ctx, broker, requester) = managed_ctx();
861 let pending = submit_pending(&broker, &requester);
862 assert!(matches!(
863 pending.request.state,
864 PermissionRequestState::Pending {
865 target: ApprovalTarget::Flow(_)
866 }
867 ));
868 let before = broker.list().len();
869 let call_ctx = crate::approval::authorize_tool_invocation(
870 &ctx,
871 "control-list",
872 "permission.list",
873 &ToolArgs::default(),
874 &PermissionList,
875 )
876 .await
877 .unwrap();
878 let listed = PermissionList
879 .call(ToolArgs::default(), &call_ctx)
880 .await
881 .unwrap();
882 let Value::Struct(fields) = listed else {
883 panic!("expected list result");
884 };
885 assert!(matches!(
886 fields.iter().find(|(name, _)| name == "requests"),
887 Some((_, Value::List(requests))) if requests.len() == 1
888 ));
889 assert_eq!(broker.list().len(), before);
890
891 let args = ToolArgs {
892 positional: Vec::new(),
893 named: vec![(
894 "request_id".into(),
895 Value::Str(pending.request.request_id.to_string()),
896 )],
897 };
898 let call_ctx = crate::approval::authorize_tool_invocation(
899 &ctx,
900 "control-approve",
901 "permission.approve",
902 &args,
903 &PermissionApprove,
904 )
905 .await
906 .unwrap();
907 PermissionApprove.call(args, &call_ctx).await.unwrap();
908
909 assert_eq!(broker.list().len(), before);
910 assert!(matches!(
911 broker.get(&pending.request.request_id).unwrap().state,
912 PermissionRequestState::Approved { .. }
913 ));
914 }
915
916 #[tokio::test]
917 async fn get_prioritizes_request_id_over_group_id() {
918 let (ctx, _broker, _requester) = managed_ctx();
919 let args = ToolArgs {
920 positional: Vec::new(),
921 named: vec![
922 ("request_id".into(), Value::Int(7)),
923 ("group_id".into(), Value::Str("ignored".into())),
924 ],
925 };
926
927 let error = PermissionGet.call(args, &ctx).await.unwrap_err();
928 assert!(matches!(
929 error,
930 RuntimeError::TypeMismatch { expected, actual }
931 if expected == "string" && actual == "int"
932 ));
933 }
934
935 #[test]
936 fn schemas_avoid_top_level_union_keywords() {
937 for schema in [PermissionGet.input_schema(), PermissionBatch.input_schema()] {
938 for keyword in ["oneOf", "allOf", "anyOf"] {
939 assert!(schema.get(keyword).is_none());
940 }
941 }
942 }
943
944 #[tokio::test]
945 async fn batch_prefers_explicit_request_ids_over_other_targets() {
946 let (ctx, broker, requester) = managed_ctx();
947 let pending = submit_pending(&broker, &requester);
948 let args = ToolArgs {
949 positional: Vec::new(),
950 named: vec![
951 (
952 "request_ids".into(),
953 Value::List(vec![Value::Str(pending.request.request_id.to_string())]),
954 ),
955 ("group_id".into(), Value::Str("not-a-group-id".into())),
956 ("action".into(), Value::Str("approve".into())),
957 ],
958 };
959
960 PermissionBatch.call(args, &ctx).await.unwrap();
961
962 assert!(matches!(
963 broker.get(&pending.request.request_id).unwrap().state,
964 PermissionRequestState::Approved { .. }
965 ));
966 }
967
968 #[tokio::test]
969 async fn batch_target_selector_does_not_expose_invisible_user_requests() {
970 let (ctx, broker, requester) = managed_ctx();
971 let pending = submit_pending(&broker, &requester);
972 let args = ToolArgs {
973 positional: Vec::new(),
974 named: vec![
975 ("selector".into(), Value::Str("target".into())),
976 ("selector_value".into(), Value::Str("user".into())),
977 ("action".into(), Value::Str("approve".into())),
978 ],
979 };
980
981 let error = PermissionBatch.call(args, &ctx).await.unwrap_err();
982 assert!(error.to_string().contains("must be flow"));
983 assert!(matches!(
984 broker.get(&pending.request.request_id).unwrap().state,
985 PermissionRequestState::Pending { .. }
986 ));
987 }
988
989 #[test]
990 fn batch_schema_rejects_unknown_fields() {
991 assert_eq!(
992 PermissionBatch.input_schema()["additionalProperties"],
993 false
994 );
995 }
996
997 #[tokio::test]
998 async fn batch_rejects_malformed_controls_without_mutating_the_broker() {
999 let cases = [
1000 ("atomic", Value::Int(1)),
1001 ("atomic", Value::Str("true".into())),
1002 ("atomic", Value::Unit),
1003 ("group_revision", Value::Int(-1)),
1004 ("group_revision", Value::Str("0".into())),
1005 ("group_revision", Value::Unit),
1006 ("group_revision", Value::Int(0)),
1007 ];
1008 for (field, value) in cases {
1009 let (ctx, broker, requester) = managed_ctx();
1010 let pending = submit_pending(&broker, &requester);
1011 let args = ToolArgs {
1012 positional: Vec::new(),
1013 named: vec![
1014 (
1015 "request_ids".into(),
1016 Value::List(vec![Value::Str(pending.request.request_id.to_string())]),
1017 ),
1018 ("action".into(), Value::Str("approve".into())),
1019 (field.into(), value),
1020 ],
1021 };
1022
1023 PermissionBatch.call(args, &ctx).await.unwrap_err();
1024 assert!(matches!(
1025 broker.get(&pending.request.request_id).unwrap().state,
1026 PermissionRequestState::Pending { .. }
1027 ));
1028 }
1029 }
1030
1031 #[tokio::test]
1032 async fn batch_rejects_caller_identity_fields_without_mutating_the_broker() {
1033 for field in [
1034 "session_id",
1035 "actor",
1036 "actor_run_id",
1037 "run_id",
1038 "requester_run_id",
1039 ] {
1040 let (ctx, broker, requester) = managed_ctx();
1041 let pending = submit_pending(&broker, &requester);
1042 let args = ToolArgs {
1043 positional: Vec::new(),
1044 named: vec![
1045 (
1046 "request_ids".into(),
1047 Value::List(vec![Value::Str(pending.request.request_id.to_string())]),
1048 ),
1049 ("action".into(), Value::Str("approve".into())),
1050 (field.into(), Value::Str("forged".into())),
1051 ],
1052 };
1053
1054 let error = PermissionBatch.call(args, &ctx).await.unwrap_err();
1055 assert!(error.to_string().contains("caller-supplied"));
1056 assert!(matches!(
1057 broker.get(&pending.request.request_id).unwrap().state,
1058 PermissionRequestState::Pending { .. }
1059 ));
1060 }
1061 }
1062
1063 #[tokio::test]
1064 async fn deny_requires_the_reason_declared_by_its_schema() {
1065 let (ctx, broker, requester) = managed_ctx();
1066 let pending = submit_pending(&broker, &requester);
1067 let args = ToolArgs {
1068 positional: Vec::new(),
1069 named: vec![(
1070 "request_id".into(),
1071 Value::Str(pending.request.request_id.to_string()),
1072 )],
1073 };
1074
1075 let error = PermissionDeny.call(args, &ctx).await.unwrap_err();
1076 assert!(matches!(error, RuntimeError::MissingArg(name) if name == "reason"));
1077 assert!(matches!(
1078 broker.get(&pending.request.request_id).unwrap().state,
1079 PermissionRequestState::Pending { .. }
1080 ));
1081 }
1082}