Skip to main content

atman_runtime/tools/
permission.rs

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(&registry);
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}