Skip to main content

harn_ir/
lib.rs

1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
2
3use harn_lexer::Span;
4use harn_parser::{Attribute, AttributeArg, BindingPattern, HitlArg, HitlKind, Node, SNode};
5
6pub type NodeId = usize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum HandlerKind {
10    Function,
11    Tool,
12    Pipeline,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct InvariantSpec {
17    pub name: String,
18    pub span: Span,
19    pub params: BTreeMap<String, String>,
20    pub positionals: Vec<String>,
21}
22
23#[derive(Debug, Clone)]
24pub struct HandlerSpec {
25    pub name: String,
26    pub kind: HandlerKind,
27    pub span: Span,
28    pub body: Vec<SNode>,
29    pub invariants: Vec<InvariantSpec>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PathStep {
34    pub span: Span,
35    pub label: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct InvariantDiagnostic {
40    pub invariant: String,
41    pub handler: String,
42    pub message: String,
43    pub span: Span,
44    pub help: Option<String>,
45    pub path: Vec<PathStep>,
46}
47
48#[derive(Debug, Clone)]
49pub struct AnalysisReport {
50    pub handlers: Vec<HandlerIr>,
51    pub diagnostics: Vec<InvariantDiagnostic>,
52}
53
54impl AnalysisReport {
55    pub fn handler(&self, name: &str) -> Option<&HandlerIr> {
56        self.handlers.iter().find(|handler| handler.name == name)
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct HandlerIr {
62    pub name: String,
63    pub kind: HandlerKind,
64    pub span: Span,
65    pub invariants: Vec<InvariantSpec>,
66    pub entry: NodeId,
67    pub exit: NodeId,
68    pub nodes: Vec<IrNode>,
69    pub edges: Vec<IrEdge>,
70}
71
72impl HandlerIr {
73    pub fn node(&self, id: NodeId) -> &IrNode {
74        &self.nodes[id]
75    }
76
77    pub fn successors(&self, id: NodeId) -> impl Iterator<Item = NodeId> + '_ {
78        self.edges
79            .iter()
80            .filter(move |edge| edge.from == id)
81            .map(|edge| edge.to)
82    }
83}
84
85#[derive(Debug, Clone)]
86pub struct IrEdge {
87    pub from: NodeId,
88    pub to: NodeId,
89}
90
91#[derive(Debug, Clone)]
92pub struct IrNode {
93    pub id: NodeId,
94    pub span: Span,
95    pub label: String,
96    pub semantics: NodeSemantics,
97}
98
99#[derive(Debug, Clone)]
100pub enum NodeSemantics {
101    Start,
102    Exit,
103    Marker,
104    Branch,
105    Call(CallSemantics),
106    Assignment(AssignmentSemantics),
107    ApprovalScopeEnter,
108    ApprovalScopeExit,
109    PolicyScopeEnter(PolicyScopeKind),
110    PolicyScopeExit(PolicyScopeKind),
111    Return,
112    Throw,
113}
114
115#[derive(Debug, Clone)]
116pub struct AssignmentSemantics {
117    pub target: Option<String>,
118    pub op: Option<String>,
119    pub value: ExprSummary,
120}
121
122#[derive(Debug, Clone)]
123pub enum ExprSummary {
124    Reference(String),
125    Call(String),
126    Binary {
127        op: String,
128        left: Box<ExprSummary>,
129        right: Box<ExprSummary>,
130    },
131    Literal,
132    Unknown,
133}
134
135#[derive(Debug, Clone)]
136pub struct CallSemantics {
137    pub name: String,
138    pub display_name: String,
139    pub classification: CallClassification,
140    pub literal_args: Vec<LiteralValue>,
141}
142
143#[derive(Debug, Clone)]
144pub enum CallClassification {
145    Other,
146    ApprovalGate,
147    BudgetRead,
148    PolicyGate(PolicyScopeKind),
149    PolicyPush(PolicyScopeKind),
150    PolicyPop(PolicyScopeKind),
151    Capabilities(Vec<CapabilityEffect>),
152}
153
154#[derive(Debug, Clone)]
155pub enum LiteralValue {
156    String(String),
157    Number(String),
158    Bool(bool),
159    Nil,
160    Identifier(String),
161    Dict(BTreeMap<String, LiteralValue>),
162    List(Vec<LiteralValue>),
163    Unknown,
164}
165
166impl LiteralValue {
167    fn as_str(&self) -> Option<&str> {
168        match self {
169            Self::String(value) | Self::Identifier(value) => Some(value.as_str()),
170            _ => None,
171        }
172    }
173
174    fn dict_field(&self, key: &str) -> Option<&LiteralValue> {
175        match self {
176            Self::Dict(entries) => entries.get(key),
177            _ => None,
178        }
179    }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
183pub enum Capability {
184    WorkspaceMutation,
185    CommandExecution,
186    NetworkAccess,
187    ConnectorAccess,
188    ModelCall,
189    WorkerDispatch,
190    HumanApproval,
191    AutonomyPolicy,
192}
193
194impl Capability {
195    fn canonical(self) -> &'static str {
196        match self {
197            Self::WorkspaceMutation => "fs.write",
198            Self::CommandExecution => "process.exec",
199            Self::NetworkAccess => "network.access",
200            Self::ConnectorAccess => "mcp.connector",
201            Self::ModelCall => "llm.model",
202            Self::WorkerDispatch => "worker.dispatch",
203            Self::HumanApproval => "human.approval",
204            Self::AutonomyPolicy => "autonomy.policy",
205        }
206    }
207
208    fn from_policy_name(raw: &str) -> Option<Self> {
209        match raw.trim().to_ascii_lowercase().as_str() {
210            "fs.write" | "fs.writes" | "workspace.write" | "workspace.mutate"
211            | "workspace.mutation" | "filesystem.write" | "filesystem.mutate" => {
212                Some(Self::WorkspaceMutation)
213            }
214            "process.exec" | "command.exec" | "command" | "exec" | "shell" => {
215                Some(Self::CommandExecution)
216            }
217            "network.access" | "network" | "http" | "sse" | "websocket" => {
218                Some(Self::NetworkAccess)
219            }
220            "mcp.connector" | "connector" | "connectors" | "mcp" | "host.tool" | "host_tool" => {
221                Some(Self::ConnectorAccess)
222            }
223            "llm.model" | "model" | "llm" | "model.call" => Some(Self::ModelCall),
224            "worker.dispatch" | "worker" | "delegated.worker" | "a2a" => Some(Self::WorkerDispatch),
225            "human.approval" | "approval" | "hitl" => Some(Self::HumanApproval),
226            "autonomy.policy" | "autonomy" => Some(Self::AutonomyPolicy),
227            _ => None,
228        }
229    }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct CapabilityEffect {
234    pub capability: Capability,
235    pub operation: String,
236    pub path: Option<String>,
237}
238
239impl CapabilityEffect {
240    fn new(capability: Capability, operation: impl Into<String>, path: Option<String>) -> Self {
241        Self {
242            capability,
243            operation: operation.into(),
244            path,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
250pub enum PolicyScopeKind {
251    Execution,
252    ToolApproval,
253    Command,
254    Egress,
255    Autonomy,
256    DynamicPermissions,
257}
258
259impl PolicyScopeKind {
260    fn label(self) -> &'static str {
261        match self {
262            Self::Execution => "execution policy",
263            Self::ToolApproval => "approval policy",
264            Self::Command => "command policy",
265            Self::Egress => "egress policy",
266            Self::Autonomy => "autonomy policy",
267            Self::DynamicPermissions => "dynamic permissions",
268        }
269    }
270}
271
272impl CallSemantics {
273    fn capability_effects(&self) -> &[CapabilityEffect] {
274        match &self.classification {
275            CallClassification::Capabilities(effects) => effects,
276            _ => &[],
277        }
278    }
279
280    fn has_budget_option(&self) -> bool {
281        self.literal_args.iter().any(literal_has_budget_policy)
282    }
283}
284
285fn literal_has_budget_policy(value: &LiteralValue) -> bool {
286    match value {
287        LiteralValue::Dict(entries) => entries.iter().any(|(key, value)| {
288            key == "budget" || key == "token_budget" || literal_has_budget_policy(value)
289        }),
290        LiteralValue::List(items) => items.iter().any(literal_has_budget_policy),
291        _ => false,
292    }
293}
294
295pub trait Invariant {
296    fn name(&self) -> &'static str;
297    fn check(&self, ir: &HandlerIr) -> Vec<InvariantDiagnostic>;
298}
299
300#[derive(Debug, Clone)]
301pub struct FsWritesSubsetPathGlob {
302    globs: Vec<String>,
303}
304
305#[derive(Debug, Clone)]
306pub struct BudgetRemainingNonIncreasing {
307    target: String,
308}
309
310#[derive(Debug, Clone, Default)]
311pub struct ApprovalReachability;
312
313#[derive(Debug, Clone)]
314pub struct CapabilityPolicyInvariant {
315    allowed: BTreeSet<Capability>,
316    workspace_globs: Vec<String>,
317    require_approval: BTreeSet<Capability>,
318    require_budget: BTreeSet<Capability>,
319    require_autonomy: BTreeSet<Capability>,
320    require_execution_policy: BTreeSet<Capability>,
321    require_command_policy: BTreeSet<Capability>,
322    require_egress_policy: BTreeSet<Capability>,
323    require_approval_policy: BTreeSet<Capability>,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
327struct CapabilityPolicyState {
328    explicit_approval: bool,
329    scoped_approval_depth: u8,
330    execution_policy_depth: u8,
331    approval_policy_depth: u8,
332    command_policy_depth: u8,
333    egress_policy_depth: u8,
334    autonomy_policy_depth: u8,
335    dynamic_permissions_depth: u8,
336    egress_policy_seen: bool,
337    budget_seen: bool,
338}
339
340impl CapabilityPolicyState {
341    fn initial() -> Self {
342        Self {
343            explicit_approval: false,
344            scoped_approval_depth: 0,
345            execution_policy_depth: 0,
346            approval_policy_depth: 0,
347            command_policy_depth: 0,
348            egress_policy_depth: 0,
349            autonomy_policy_depth: 0,
350            dynamic_permissions_depth: 0,
351            egress_policy_seen: false,
352            budget_seen: false,
353        }
354    }
355
356    fn is_approved(self) -> bool {
357        self.explicit_approval || self.scoped_approval_depth > 0
358    }
359
360    fn has_execution_policy(self) -> bool {
361        self.execution_policy_depth > 0 || self.dynamic_permissions_depth > 0
362    }
363
364    fn has_command_policy(self) -> bool {
365        self.command_policy_depth > 0 || self.has_execution_policy()
366    }
367
368    fn has_egress_policy(self) -> bool {
369        self.egress_policy_depth > 0 || self.egress_policy_seen || self.has_execution_policy()
370    }
371
372    fn has_autonomy_policy(self) -> bool {
373        self.autonomy_policy_depth > 0
374    }
375
376    fn has_approval_policy(self) -> bool {
377        self.approval_policy_depth > 0
378    }
379}
380
381struct CapabilityCheckContext<'a, 'b> {
382    ir: &'a HandlerIr,
383    node: &'a IrNode,
384    call: &'a CallSemantics,
385    effect: &'a CapabilityEffect,
386    path: &'a [PathStep],
387    reported: &'b mut BTreeSet<(NodeId, Capability, &'static str)>,
388    diagnostics: &'b mut Vec<InvariantDiagnostic>,
389}
390
391impl Invariant for FsWritesSubsetPathGlob {
392    fn name(&self) -> &'static str {
393        "fs.writes"
394    }
395
396    fn check(&self, ir: &HandlerIr) -> Vec<InvariantDiagnostic> {
397        let mut diagnostics = Vec::new();
398        let mut seen = BTreeSet::new();
399        for node in &ir.nodes {
400            let NodeSemantics::Call(call) = &node.semantics else {
401                continue;
402            };
403            let Some(effect) = call
404                .capability_effects()
405                .iter()
406                .find(|effect| effect.capability == Capability::WorkspaceMutation)
407            else {
408                continue;
409            };
410
411            let message = match effect.path.as_deref() {
412                Some(path) if self.globs.iter().any(|glob| glob_match(glob, path)) => continue,
413                Some(path) => format!(
414                    "write path `{path}` is outside the allowed glob(s): {}",
415                    self.globs.join(", ")
416                ),
417                None => format!(
418                    "could not prove `{}` stays within the allowed glob(s): {}",
419                    call.display_name,
420                    self.globs.join(", ")
421                ),
422            };
423
424            if !seen.insert(node.id) {
425                continue;
426            }
427
428            diagnostics.push(InvariantDiagnostic {
429                invariant: self.name().to_string(),
430                handler: ir.name.clone(),
431                message,
432                span: node.span,
433                help: Some(
434                    "use a literal path that matches the declared glob, or narrow the dynamic path before writing".to_string(),
435                ),
436                path: path_to_node(ir, node.id),
437            });
438        }
439        diagnostics
440    }
441}
442
443impl Invariant for BudgetRemainingNonIncreasing {
444    fn name(&self) -> &'static str {
445        "budget.remaining"
446    }
447
448    fn check(&self, ir: &HandlerIr) -> Vec<InvariantDiagnostic> {
449        let mut diagnostics = Vec::new();
450        let mut seen = BTreeSet::new();
451        for node in &ir.nodes {
452            let NodeSemantics::Assignment(assignment) = &node.semantics else {
453                continue;
454            };
455            if assignment.target.as_deref() != Some(self.target.as_str()) {
456                continue;
457            }
458            if assignment_is_non_increasing(assignment, &self.target) {
459                continue;
460            }
461            if !seen.insert(node.id) {
462                continue;
463            }
464            diagnostics.push(InvariantDiagnostic {
465                invariant: self.name().to_string(),
466                handler: ir.name.clone(),
467                message: format!(
468                    "assignment to `{}` may increase it; only self-subtractions, identity assignments, or `llm_budget_remaining()` refreshes are accepted",
469                    self.target
470                ),
471                span: node.span,
472                help: Some(
473                    "rewrite the update as `target = target - delta`, `target -= delta`, or refresh it from `llm_budget_remaining()`".to_string(),
474                ),
475                path: path_to_node(ir, node.id),
476            });
477        }
478        diagnostics
479    }
480}
481
482impl Invariant for ApprovalReachability {
483    fn name(&self) -> &'static str {
484        "approval.reachability"
485    }
486
487    fn check(&self, ir: &HandlerIr) -> Vec<InvariantDiagnostic> {
488        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
489        struct State {
490            explicit_approval: bool,
491            scoped_approval_depth: u8,
492        }
493
494        impl State {
495            fn is_approved(self) -> bool {
496                self.explicit_approval || self.scoped_approval_depth > 0
497            }
498        }
499
500        let mut diagnostics = Vec::new();
501        let mut queue = VecDeque::new();
502        let mut visited = HashSet::new();
503        let mut reported = BTreeSet::new();
504
505        queue.push_back((
506            ir.entry,
507            State {
508                explicit_approval: false,
509                scoped_approval_depth: 0,
510            },
511            vec![PathStep {
512                span: ir.node(ir.entry).span,
513                label: ir.node(ir.entry).label.clone(),
514            }],
515        ));
516
517        while let Some((node_id, state, path)) = queue.pop_front() {
518            if !visited.insert((node_id, state)) {
519                continue;
520            }
521
522            let node = ir.node(node_id);
523            let mut next_state = state;
524            match &node.semantics {
525                NodeSemantics::Call(call) => match &call.classification {
526                    CallClassification::ApprovalGate => {
527                        next_state.explicit_approval = true;
528                    }
529                    CallClassification::Capabilities(effects) => {
530                        for effect in effects {
531                            if state.is_approved() || !reported.insert((node_id, effect.capability))
532                            {
533                                continue;
534                            }
535                            diagnostics.push(InvariantDiagnostic {
536                                invariant: self.name().to_string(),
537                                handler: ir.name.clone(),
538                                message: format!(
539                                    "side-effecting call `{}` for capability `{}` is reachable before any approval gate",
540                                    call.display_name,
541                                    effect.capability.canonical()
542                                ),
543                                span: node.span,
544                                help: Some(
545                                    "call `request_approval(...)` earlier on every path, or move the side effect into a `dual_control(...)` closure".to_string(),
546                                ),
547                                path: path.clone(),
548                            });
549                        }
550                    }
551                    _ => {}
552                },
553                NodeSemantics::ApprovalScopeEnter => {
554                    next_state.scoped_approval_depth =
555                        next_state.scoped_approval_depth.saturating_add(1);
556                }
557                NodeSemantics::ApprovalScopeExit => {
558                    next_state.scoped_approval_depth =
559                        next_state.scoped_approval_depth.saturating_sub(1);
560                }
561                _ => {}
562            }
563
564            for succ in ir.successors(node_id) {
565                let succ_node = ir.node(succ);
566                let mut next_path = path.clone();
567                next_path.push(PathStep {
568                    span: succ_node.span,
569                    label: succ_node.label.clone(),
570                });
571                queue.push_back((succ, next_state, next_path));
572            }
573        }
574
575        diagnostics
576    }
577}
578
579impl Invariant for CapabilityPolicyInvariant {
580    fn name(&self) -> &'static str {
581        "capability.policy"
582    }
583
584    fn check(&self, ir: &HandlerIr) -> Vec<InvariantDiagnostic> {
585        let mut diagnostics = Vec::new();
586        let mut queue = VecDeque::new();
587        let mut visited = HashSet::new();
588        let mut reported = BTreeSet::new();
589
590        queue.push_back((
591            ir.entry,
592            CapabilityPolicyState::initial(),
593            vec![PathStep {
594                span: ir.node(ir.entry).span,
595                label: ir.node(ir.entry).label.clone(),
596            }],
597        ));
598
599        while let Some((node_id, state, path)) = queue.pop_front() {
600            if !visited.insert((node_id, state)) {
601                continue;
602            }
603
604            let node = ir.node(node_id);
605            let mut next_state = state;
606            match &node.semantics {
607                NodeSemantics::Call(call) => match &call.classification {
608                    CallClassification::ApprovalGate => next_state.explicit_approval = true,
609                    CallClassification::BudgetRead => next_state.budget_seen = true,
610                    CallClassification::PolicyGate(PolicyScopeKind::Egress) => {
611                        next_state.egress_policy_seen = true;
612                    }
613                    CallClassification::PolicyGate(_) => {}
614                    CallClassification::PolicyPush(kind) => {
615                        increment_policy_depth(&mut next_state, *kind);
616                    }
617                    CallClassification::PolicyPop(kind) => {
618                        decrement_policy_depth(&mut next_state, *kind);
619                    }
620                    CallClassification::Capabilities(effects) => {
621                        for effect in effects {
622                            let mut context = CapabilityCheckContext {
623                                ir,
624                                node,
625                                call,
626                                effect,
627                                path: &path,
628                                reported: &mut reported,
629                                diagnostics: &mut diagnostics,
630                            };
631                            self.check_effect(state, &mut context);
632                        }
633                    }
634                    CallClassification::Other => {}
635                },
636                NodeSemantics::ApprovalScopeEnter => {
637                    next_state.scoped_approval_depth =
638                        next_state.scoped_approval_depth.saturating_add(1);
639                }
640                NodeSemantics::ApprovalScopeExit => {
641                    next_state.scoped_approval_depth =
642                        next_state.scoped_approval_depth.saturating_sub(1);
643                }
644                NodeSemantics::PolicyScopeEnter(kind) => {
645                    increment_policy_depth(&mut next_state, *kind);
646                }
647                NodeSemantics::PolicyScopeExit(kind) => {
648                    decrement_policy_depth(&mut next_state, *kind);
649                }
650                _ => {}
651            }
652
653            for succ in ir.successors(node_id) {
654                let succ_node = ir.node(succ);
655                let mut next_path = path.clone();
656                next_path.push(PathStep {
657                    span: succ_node.span,
658                    label: succ_node.label.clone(),
659                });
660                queue.push_back((succ, next_state, next_path));
661            }
662        }
663
664        diagnostics
665    }
666}
667
668impl CapabilityPolicyInvariant {
669    fn check_effect(
670        &self,
671        state: CapabilityPolicyState,
672        context: &mut CapabilityCheckContext<'_, '_>,
673    ) {
674        let capability = context.effect.capability;
675        if !self.allowed.contains(&capability)
676            && context
677                .reported
678                .insert((context.node.id, capability, "allow"))
679        {
680            context.diagnostics.push(InvariantDiagnostic {
681                invariant: self.name().to_string(),
682                handler: context.ir.name.clone(),
683                message: format!(
684                    "handler `{}` can reach capability `{}` via `{}` but that capability is not declared in `@invariant(\"capability.policy\", allow: ...)`",
685                    context.ir.name,
686                    capability.canonical(),
687                    context.effect.operation
688                ),
689                span: context.node.span,
690                help: Some(format!(
691                    "add `{}` to the invariant's `allow:` list or remove the reachable call",
692                    capability.canonical()
693                )),
694                path: context.path.to_vec(),
695            });
696            return;
697        }
698
699        if capability == Capability::WorkspaceMutation {
700            self.check_workspace_path(context);
701        }
702        self.check_required_gate(state, context);
703    }
704
705    fn check_workspace_path(&self, context: &mut CapabilityCheckContext<'_, '_>) {
706        if self.workspace_globs.is_empty() {
707            return;
708        }
709        let message = match context.effect.path.as_deref() {
710            Some(path)
711                if self
712                    .workspace_globs
713                    .iter()
714                    .any(|glob| glob_match(glob, path)) =>
715            {
716                return;
717            }
718            Some(path) => format!(
719                "handler `{}` can reach capability `{}` via `{}` with path `{path}` outside the allowed workspace glob(s): {}",
720                context.ir.name,
721                context.effect.capability.canonical(),
722                context.call.display_name,
723                self.workspace_globs.join(", ")
724            ),
725            None => format!(
726                "handler `{}` can reach capability `{}` via `{}` but the target path is not a literal proven inside the allowed workspace glob(s): {}",
727                context.ir.name,
728                context.effect.capability.canonical(),
729                context.call.display_name,
730                self.workspace_globs.join(", ")
731            ),
732        };
733        if context
734            .reported
735            .insert((context.node.id, context.effect.capability, "workspace"))
736        {
737            context.diagnostics.push(InvariantDiagnostic {
738                invariant: self.name().to_string(),
739                handler: context.ir.name.clone(),
740                message,
741                span: context.node.span,
742                help: Some(
743                    "use a literal path inside the declared workspace glob or narrow the policy"
744                        .to_string(),
745                ),
746                path: context.path.to_vec(),
747            });
748        }
749    }
750
751    fn check_required_gate(
752        &self,
753        state: CapabilityPolicyState,
754        context: &mut CapabilityCheckContext<'_, '_>,
755    ) {
756        let capability = context.effect.capability;
757        if self.require_approval.contains(&capability) && !state.is_approved() {
758            self.push_missing_gate(
759                context,
760                "approval",
761                "human approval gate",
762                "call `request_approval(...)` earlier on every path or wrap the action in `dual_control(...)`",
763            );
764        }
765        if self.require_budget.contains(&capability)
766            && !state.budget_seen
767            && !context.call.has_budget_option()
768        {
769            self.push_missing_gate(
770                context,
771                "budget",
772                "budget policy",
773                "thread a `llm_budget_remaining()` check before the call or pass a literal `budget:` option",
774            );
775        }
776        if self.require_autonomy.contains(&capability) && !state.has_autonomy_policy() {
777            self.push_missing_gate(
778                context,
779                "autonomy",
780                "autonomy policy",
781                "wrap the reachable call in `with_autonomy_policy(...)`",
782            );
783        }
784        if self.require_execution_policy.contains(&capability) && !state.has_execution_policy() {
785            self.push_missing_gate(
786                context,
787                "execution",
788                "execution policy",
789                "wrap the reachable call in `with_execution_policy(...)` or `with_dynamic_permissions(...)`",
790            );
791        }
792        if self.require_command_policy.contains(&capability) && !state.has_command_policy() {
793            self.push_missing_gate(
794                context,
795                "command",
796                "command policy",
797                "wrap the reachable command in `with_command_policy(...)` or install `command_policy_push(...)` before it",
798            );
799        }
800        if self.require_egress_policy.contains(&capability) && !state.has_egress_policy() {
801            self.push_missing_gate(
802                context,
803                "egress",
804                "egress policy",
805                "install `egress_policy(...)` before the reachable network or connector call",
806            );
807        }
808        if self.require_approval_policy.contains(&capability) && !state.has_approval_policy() {
809            self.push_missing_gate(
810                context,
811                "approval_policy",
812                "tool approval policy",
813                "wrap the reachable tool call in `with_approval_policy(...)`",
814            );
815        }
816    }
817
818    fn push_missing_gate(
819        &self,
820        context: &mut CapabilityCheckContext<'_, '_>,
821        gate_key: &'static str,
822        gate_label: &'static str,
823        help: &str,
824    ) {
825        if !context
826            .reported
827            .insert((context.node.id, context.effect.capability, gate_key))
828        {
829            return;
830        }
831        context.diagnostics.push(InvariantDiagnostic {
832            invariant: self.name().to_string(),
833            handler: context.ir.name.clone(),
834            message: format!(
835                "handler `{}` can reach capability `{}` via `{}` without the required {gate_label}",
836                context.ir.name,
837                context.effect.capability.canonical(),
838                context.call.display_name
839            ),
840            span: context.node.span,
841            help: Some(help.to_string()),
842            path: context.path.to_vec(),
843        });
844    }
845}
846
847fn increment_policy_depth(state: &mut CapabilityPolicyState, kind: PolicyScopeKind) {
848    match kind {
849        PolicyScopeKind::Execution => {
850            state.execution_policy_depth = state.execution_policy_depth.saturating_add(1);
851        }
852        PolicyScopeKind::ToolApproval => {
853            state.approval_policy_depth = state.approval_policy_depth.saturating_add(1);
854        }
855        PolicyScopeKind::Command => {
856            state.command_policy_depth = state.command_policy_depth.saturating_add(1);
857        }
858        PolicyScopeKind::Egress => {
859            state.egress_policy_depth = state.egress_policy_depth.saturating_add(1);
860        }
861        PolicyScopeKind::Autonomy => {
862            state.autonomy_policy_depth = state.autonomy_policy_depth.saturating_add(1);
863        }
864        PolicyScopeKind::DynamicPermissions => {
865            state.dynamic_permissions_depth = state.dynamic_permissions_depth.saturating_add(1);
866        }
867    }
868}
869
870fn decrement_policy_depth(state: &mut CapabilityPolicyState, kind: PolicyScopeKind) {
871    match kind {
872        PolicyScopeKind::Execution => {
873            state.execution_policy_depth = state.execution_policy_depth.saturating_sub(1);
874        }
875        PolicyScopeKind::ToolApproval => {
876            state.approval_policy_depth = state.approval_policy_depth.saturating_sub(1);
877        }
878        PolicyScopeKind::Command => {
879            state.command_policy_depth = state.command_policy_depth.saturating_sub(1);
880        }
881        PolicyScopeKind::Egress => {
882            state.egress_policy_depth = state.egress_policy_depth.saturating_sub(1);
883        }
884        PolicyScopeKind::Autonomy => {
885            state.autonomy_policy_depth = state.autonomy_policy_depth.saturating_sub(1);
886        }
887        PolicyScopeKind::DynamicPermissions => {
888            state.dynamic_permissions_depth = state.dynamic_permissions_depth.saturating_sub(1);
889        }
890    }
891}
892
893pub fn analyze_program(program: &[SNode]) -> AnalysisReport {
894    let (handlers, mut diagnostics) = collect_handlers(program);
895    let mut irs = Vec::with_capacity(handlers.len());
896
897    for handler in handlers {
898        let ir = HandlerIrBuilder::new(&handler).build();
899        for spec in &handler.invariants {
900            match instantiate_invariant(spec) {
901                Ok(invariant) => diagnostics.extend(invariant.check(&ir)),
902                Err(diag) => diagnostics.push(diag.with_handler(&handler.name)),
903            }
904        }
905        irs.push(ir);
906    }
907
908    AnalysisReport {
909        handlers: irs,
910        diagnostics,
911    }
912}
913
914pub fn explain_handler_invariant(
915    program: &[SNode],
916    handler_name: &str,
917    invariant_name: &str,
918) -> Result<Vec<InvariantDiagnostic>, String> {
919    let (handlers, config_diags) = collect_handlers(program);
920    let Some(handler) = handlers.iter().find(|handler| handler.name == handler_name) else {
921        return Err(format!("handler `{handler_name}` was not found"));
922    };
923    if let Some(diag) = config_diags
924        .into_iter()
925        .find(|diag| diag.handler == handler.name || diag.handler.is_empty())
926    {
927        return Ok(vec![diag]);
928    }
929    let normalized = normalize_invariant_name(invariant_name)
930        .ok_or_else(|| format!("unknown invariant `{invariant_name}`"))?;
931    let Some(spec) = handler
932        .invariants
933        .iter()
934        .find(|spec| spec.name == normalized)
935        .cloned()
936    else {
937        return Err(format!(
938            "handler `{handler_name}` does not declare `@invariant(\"{normalized}\")`"
939        ));
940    };
941    let invariant = instantiate_invariant(&spec).map_err(|diag| diag.message)?;
942    let ir = HandlerIrBuilder::new(handler).build();
943    Ok(invariant.check(&ir))
944}
945
946fn collect_handlers(program: &[SNode]) -> (Vec<HandlerSpec>, Vec<InvariantDiagnostic>) {
947    let mut handlers = Vec::new();
948    let mut diagnostics = Vec::new();
949
950    for node in program {
951        let (attributes, inner) = match &node.node {
952            Node::AttributedDecl { attributes, inner } => (attributes.as_slice(), inner.as_ref()),
953            _ => (&[][..], node),
954        };
955        let Some((name, kind, body)) = handler_decl(inner) else {
956            continue;
957        };
958        let (invariants, mut invariant_diags) = parse_invariant_specs(attributes, name, kind);
959        diagnostics.append(&mut invariant_diags);
960        handlers.push(HandlerSpec {
961            name: name.to_string(),
962            kind,
963            span: inner.span,
964            body: body.to_vec(),
965            invariants,
966        });
967    }
968
969    (handlers, diagnostics)
970}
971
972fn handler_decl(node: &SNode) -> Option<(&str, HandlerKind, &[SNode])> {
973    match &node.node {
974        Node::FnDecl { name, body, .. } => Some((name.as_str(), HandlerKind::Function, body)),
975        Node::ToolDecl { name, body, .. } => Some((name.as_str(), HandlerKind::Tool, body)),
976        Node::Pipeline { name, body, .. } => Some((name.as_str(), HandlerKind::Pipeline, body)),
977        _ => None,
978    }
979}
980
981fn parse_invariant_specs(
982    attributes: &[Attribute],
983    handler_name: &str,
984    handler_kind: HandlerKind,
985) -> (Vec<InvariantSpec>, Vec<InvariantDiagnostic>) {
986    let mut specs = Vec::new();
987    let mut diagnostics = Vec::new();
988
989    for attribute in attributes {
990        if attribute.name != "invariant" {
991            continue;
992        }
993        if !matches!(
994            handler_kind,
995            HandlerKind::Function | HandlerKind::Tool | HandlerKind::Pipeline
996        ) {
997            diagnostics.push(InvariantDiagnostic {
998                invariant: "invariant".to_string(),
999                handler: handler_name.to_string(),
1000                message: "`@invariant` only applies to function, tool, or pipeline declarations"
1001                    .to_string(),
1002                span: attribute.span,
1003                help: None,
1004                path: Vec::new(),
1005            });
1006            continue;
1007        }
1008
1009        match parse_invariant_spec(attribute) {
1010            Ok(spec) => specs.push(spec),
1011            Err(mut diag) => {
1012                diag.handler = handler_name.to_string();
1013                diagnostics.push(*diag);
1014            }
1015        }
1016    }
1017
1018    (specs, diagnostics)
1019}
1020
1021fn parse_invariant_spec(attribute: &Attribute) -> Result<InvariantSpec, Box<InvariantDiagnostic>> {
1022    let mut named = BTreeMap::new();
1023    let mut positionals = Vec::new();
1024
1025    for arg in &attribute.args {
1026        let Some(value) = attribute_arg_string(arg) else {
1027            return Err(Box::new(InvariantDiagnostic {
1028                invariant: "invariant".to_string(),
1029                handler: String::new(),
1030                message: "`@invariant(...)` arguments must be strings, identifiers, numbers, bools, or nil".to_string(),
1031                span: arg.span,
1032                help: Some("use strings for invariant names and configuration values".to_string()),
1033                path: Vec::new(),
1034            }));
1035        };
1036        if let Some(name) = &arg.name {
1037            named.insert(name.clone(), value);
1038        } else {
1039            positionals.push(value);
1040        }
1041    }
1042
1043    let raw_name = named
1044        .remove("name")
1045        .or_else(|| positionals.first().cloned())
1046        .ok_or_else(|| Box::new(InvariantDiagnostic {
1047            invariant: "invariant".to_string(),
1048            handler: String::new(),
1049            message: "`@invariant(...)` requires an invariant name as the first positional argument or `name:`".to_string(),
1050            span: attribute.span,
1051            help: Some(
1052                "for example: `@invariant(\"fs.writes\", \"src/**\")`".to_string(),
1053            ),
1054            path: Vec::new(),
1055        }))?;
1056    let name = normalize_invariant_name(&raw_name).ok_or_else(|| {
1057        Box::new(InvariantDiagnostic {
1058            invariant: raw_name.clone(),
1059            handler: String::new(),
1060            message: format!("unknown invariant `{raw_name}`"),
1061            span: attribute.span,
1062            help: Some(
1063                "known invariants are `fs.writes`, `budget.remaining`, `approval.reachability`, and `capability.policy`"
1064                    .to_string(),
1065            ),
1066            path: Vec::new(),
1067        })
1068    })?;
1069
1070    let remaining_positionals = if named.contains_key("name") {
1071        positionals
1072    } else {
1073        positionals.into_iter().skip(1).collect()
1074    };
1075
1076    Ok(InvariantSpec {
1077        name,
1078        span: attribute.span,
1079        params: named,
1080        positionals: remaining_positionals,
1081    })
1082}
1083
1084fn attribute_arg_string(arg: &AttributeArg) -> Option<String> {
1085    match &arg.value.node {
1086        Node::StringLiteral(value) | Node::RawStringLiteral(value) | Node::Identifier(value) => {
1087            Some(value.clone())
1088        }
1089        Node::IntLiteral(value) => Some(value.to_string()),
1090        Node::FloatLiteral(value) => Some(value.to_string()),
1091        Node::BoolLiteral(value) => Some(value.to_string()),
1092        Node::NilLiteral => Some("nil".to_string()),
1093        _ => None,
1094    }
1095}
1096
1097fn normalize_invariant_name(name: &str) -> Option<String> {
1098    match name {
1099        "fs.writes" | "fs_writes" | "writes" => Some("fs.writes".to_string()),
1100        "budget.remaining" | "budget_remaining" | "budget" => Some("budget.remaining".to_string()),
1101        "approval.reachability" | "approval_reachability" | "approval" => {
1102            Some("approval.reachability".to_string())
1103        }
1104        "capability.policy" | "capability_policy" | "capabilities" | "policy.capabilities" => {
1105            Some("capability.policy".to_string())
1106        }
1107        _ => None,
1108    }
1109}
1110
1111fn instantiate_invariant(
1112    spec: &InvariantSpec,
1113) -> Result<Box<dyn Invariant>, ConfigDiagnosticBuilder> {
1114    match spec.name.as_str() {
1115        "fs.writes" => {
1116            let mut globs = spec.positionals.clone();
1117            if let Some(glob) = spec
1118                .params
1119                .get("path_glob")
1120                .or_else(|| spec.params.get("glob"))
1121                .or_else(|| spec.params.get("allow"))
1122            {
1123                globs.push(glob.clone());
1124            }
1125            if globs.is_empty() {
1126                return Err(ConfigDiagnosticBuilder::new(
1127                    "fs.writes",
1128                    spec.span,
1129                    "`fs.writes` requires at least one allowed path glob".to_string(),
1130                    Some("for example: `@invariant(\"fs.writes\", \"src/**\")`".to_string()),
1131                ));
1132            }
1133            Ok(Box::new(FsWritesSubsetPathGlob { globs }))
1134        }
1135        "budget.remaining" => {
1136            let target = spec
1137                .params
1138                .get("target")
1139                .cloned()
1140                .or_else(|| spec.positionals.first().cloned())
1141                .unwrap_or_else(|| "budget.remaining".to_string());
1142            Ok(Box::new(BudgetRemainingNonIncreasing { target }))
1143        }
1144        "approval.reachability" => Ok(Box::new(ApprovalReachability)),
1145        "capability.policy" => instantiate_capability_policy_invariant(spec),
1146        other => Err(ConfigDiagnosticBuilder::new(
1147            other,
1148            spec.span,
1149            format!("unknown invariant `{other}`"),
1150            None,
1151        )),
1152    }
1153}
1154
1155fn instantiate_capability_policy_invariant(
1156    spec: &InvariantSpec,
1157) -> Result<Box<dyn Invariant>, ConfigDiagnosticBuilder> {
1158    let allow_raw = spec
1159        .params
1160        .get("allow")
1161        .or_else(|| spec.params.get("capabilities"))
1162        .or_else(|| spec.params.get("allow_capabilities"))
1163        .or_else(|| spec.positionals.first())
1164        .ok_or_else(|| {
1165            ConfigDiagnosticBuilder::new(
1166                "capability.policy",
1167                spec.span,
1168                "`capability.policy` requires an `allow:` capability list".to_string(),
1169                Some(
1170                    "for example: `@invariant(\"capability.policy\", allow: \"fs.write,llm.model\")`"
1171                        .to_string(),
1172                ),
1173            )
1174        })?;
1175    let allowed = parse_capability_set(allow_raw).map_err(|message| {
1176        ConfigDiagnosticBuilder::new("capability.policy", spec.span, message, capability_help())
1177    })?;
1178    if allowed.is_empty() {
1179        return Err(ConfigDiagnosticBuilder::new(
1180            "capability.policy",
1181            spec.span,
1182            "`capability.policy` allow list must contain at least one capability".to_string(),
1183            capability_help(),
1184        ));
1185    }
1186
1187    let workspace_globs = collect_named_values(
1188        spec,
1189        &[
1190            "workspace",
1191            "workspace_glob",
1192            "path_glob",
1193            "glob",
1194            "allow_workspace",
1195        ],
1196    );
1197
1198    Ok(Box::new(CapabilityPolicyInvariant {
1199        allowed,
1200        workspace_globs,
1201        require_approval: parse_optional_capability_set(spec, &["require_approval"])?,
1202        require_budget: parse_optional_capability_set(spec, &["require_budget", "budget"])?,
1203        require_autonomy: parse_optional_capability_set(spec, &["require_autonomy"])?,
1204        require_execution_policy: parse_optional_capability_set(
1205            spec,
1206            &["require_execution_policy", "require_sandbox"],
1207        )?,
1208        require_command_policy: parse_optional_capability_set(spec, &["require_command_policy"])?,
1209        require_egress_policy: parse_optional_capability_set(spec, &["require_egress_policy"])?,
1210        require_approval_policy: parse_optional_capability_set(spec, &["require_approval_policy"])?,
1211    }))
1212}
1213
1214fn parse_optional_capability_set(
1215    spec: &InvariantSpec,
1216    keys: &[&str],
1217) -> Result<BTreeSet<Capability>, ConfigDiagnosticBuilder> {
1218    let Some(raw) = keys.iter().find_map(|key| spec.params.get(*key)) else {
1219        return Ok(BTreeSet::new());
1220    };
1221    parse_capability_set(raw).map_err(|message| {
1222        ConfigDiagnosticBuilder::new("capability.policy", spec.span, message, capability_help())
1223    })
1224}
1225
1226fn collect_named_values(spec: &InvariantSpec, keys: &[&str]) -> Vec<String> {
1227    keys.iter()
1228        .filter_map(|key| spec.params.get(*key).cloned())
1229        .flat_map(|value| split_config_list(&value))
1230        .collect()
1231}
1232
1233fn parse_capability_set(raw: &str) -> Result<BTreeSet<Capability>, String> {
1234    let mut capabilities = BTreeSet::new();
1235    for item in split_config_list(raw) {
1236        let Some(capability) = Capability::from_policy_name(&item) else {
1237            return Err(format!(
1238                "unknown capability `{item}` in `capability.policy`"
1239            ));
1240        };
1241        capabilities.insert(capability);
1242    }
1243    Ok(capabilities)
1244}
1245
1246fn split_config_list(raw: &str) -> Vec<String> {
1247    raw.split(|ch: char| ch == ',' || ch == ';' || ch.is_whitespace())
1248        .map(str::trim)
1249        .filter(|item| !item.is_empty())
1250        .map(str::to_string)
1251        .collect()
1252}
1253
1254fn capability_help() -> Option<String> {
1255    Some(
1256        "known capabilities are `fs.write`, `process.exec`, `network.access`, `mcp.connector`, `llm.model`, `worker.dispatch`, `human.approval`, and `autonomy.policy`"
1257            .to_string(),
1258    )
1259}
1260
1261#[derive(Debug, Clone)]
1262struct ConfigDiagnosticBuilder {
1263    invariant: String,
1264    span: Span,
1265    message: String,
1266    help: Option<String>,
1267}
1268
1269impl ConfigDiagnosticBuilder {
1270    fn new(
1271        invariant: impl Into<String>,
1272        span: Span,
1273        message: String,
1274        help: Option<String>,
1275    ) -> Self {
1276        Self {
1277            invariant: invariant.into(),
1278            span,
1279            message,
1280            help,
1281        }
1282    }
1283
1284    fn with_handler(self, handler: &str) -> InvariantDiagnostic {
1285        InvariantDiagnostic {
1286            invariant: self.invariant,
1287            handler: handler.to_string(),
1288            message: self.message,
1289            span: self.span,
1290            help: self.help,
1291            path: Vec::new(),
1292        }
1293    }
1294}
1295
1296struct HandlerIrBuilder<'a> {
1297    handler: &'a HandlerSpec,
1298    nodes: Vec<IrNode>,
1299    edges: Vec<IrEdge>,
1300}
1301
1302impl<'a> HandlerIrBuilder<'a> {
1303    fn new(handler: &'a HandlerSpec) -> Self {
1304        Self {
1305            handler,
1306            nodes: Vec::new(),
1307            edges: Vec::new(),
1308        }
1309    }
1310
1311    fn build(mut self) -> HandlerIr {
1312        let entry = self.push_node(
1313            self.handler.span,
1314            "enter handler".to_string(),
1315            NodeSemantics::Start,
1316        );
1317        let exit = self.push_node(
1318            self.handler.span,
1319            "exit handler".to_string(),
1320            NodeSemantics::Exit,
1321        );
1322        let exits = self.build_block(&self.handler.body, vec![entry]);
1323        self.connect_all(&exits, exit);
1324        HandlerIr {
1325            name: self.handler.name.clone(),
1326            kind: self.handler.kind,
1327            span: self.handler.span,
1328            invariants: self.handler.invariants.clone(),
1329            entry,
1330            exit,
1331            nodes: self.nodes,
1332            edges: self.edges,
1333        }
1334    }
1335
1336    fn push_node(&mut self, span: Span, label: String, semantics: NodeSemantics) -> NodeId {
1337        let id = self.nodes.len();
1338        self.nodes.push(IrNode {
1339            id,
1340            span,
1341            label,
1342            semantics,
1343        });
1344        id
1345    }
1346
1347    fn connect(&mut self, from: NodeId, to: NodeId) {
1348        self.edges.push(IrEdge { from, to });
1349    }
1350
1351    fn connect_all(&mut self, from: &[NodeId], to: NodeId) {
1352        for &edge_from in from {
1353            self.connect(edge_from, to);
1354        }
1355    }
1356
1357    fn build_block(&mut self, nodes: &[SNode], incoming: Vec<NodeId>) -> Vec<NodeId> {
1358        let mut exits = incoming;
1359        for node in nodes {
1360            exits = self.build_stmt(node, exits);
1361        }
1362        exits
1363    }
1364
1365    fn build_stmt(&mut self, node: &SNode, incoming: Vec<NodeId>) -> Vec<NodeId> {
1366        match &node.node {
1367            Node::LetBinding { pattern, value, .. } | Node::VarBinding { pattern, value, .. } => {
1368                let exits = self.build_expr(value, incoming);
1369                if let BindingPattern::Identifier(name) = pattern {
1370                    let assignment = self.push_node(
1371                        node.span,
1372                        format!("assign {name}"),
1373                        NodeSemantics::Assignment(AssignmentSemantics {
1374                            target: Some(name.clone()),
1375                            op: None,
1376                            value: expr_summary(value),
1377                        }),
1378                    );
1379                    self.connect_all(&exits, assignment);
1380                    vec![assignment]
1381                } else {
1382                    exits
1383                }
1384            }
1385            Node::Assignment { target, value, op } => {
1386                let exits = self.build_expr(value, incoming);
1387                let assignment = self.push_node(
1388                    node.span,
1389                    format!(
1390                        "assign {}",
1391                        target_path(target).unwrap_or_else(|| "target".to_string())
1392                    ),
1393                    NodeSemantics::Assignment(AssignmentSemantics {
1394                        target: target_path(target),
1395                        op: op.clone(),
1396                        value: expr_summary(value),
1397                    }),
1398                );
1399                self.connect_all(&exits, assignment);
1400                vec![assignment]
1401            }
1402            Node::IfElse {
1403                condition,
1404                then_body,
1405                else_body,
1406            } => {
1407                let cond_exits = self.build_expr(condition, incoming);
1408                let branch =
1409                    self.push_node(node.span, "if condition".to_string(), NodeSemantics::Branch);
1410                self.connect_all(&cond_exits, branch);
1411
1412                let then_entry =
1413                    self.push_node(node.span, "if true".to_string(), NodeSemantics::Marker);
1414                self.connect(branch, then_entry);
1415                let mut exits = self.build_block(then_body, vec![then_entry]);
1416
1417                if let Some(else_body) = else_body {
1418                    let else_entry =
1419                        self.push_node(node.span, "if false".to_string(), NodeSemantics::Marker);
1420                    self.connect(branch, else_entry);
1421                    exits.extend(self.build_block(else_body, vec![else_entry]));
1422                } else {
1423                    let fallthrough =
1424                        self.push_node(node.span, "if false".to_string(), NodeSemantics::Marker);
1425                    self.connect(branch, fallthrough);
1426                    exits.push(fallthrough);
1427                }
1428
1429                exits
1430            }
1431            Node::GuardStmt {
1432                condition,
1433                else_body,
1434            } => {
1435                let cond_exits = self.build_expr(condition, incoming);
1436                let branch = self.push_node(
1437                    node.span,
1438                    "guard condition".to_string(),
1439                    NodeSemantics::Branch,
1440                );
1441                self.connect_all(&cond_exits, branch);
1442
1443                let success =
1444                    self.push_node(node.span, "guard passed".to_string(), NodeSemantics::Marker);
1445                self.connect(branch, success);
1446
1447                let else_entry =
1448                    self.push_node(node.span, "guard failed".to_string(), NodeSemantics::Marker);
1449                self.connect(branch, else_entry);
1450
1451                let mut exits = vec![success];
1452                exits.extend(self.build_block(else_body, vec![else_entry]));
1453                exits
1454            }
1455            Node::ForIn { iterable, body, .. } => {
1456                let iter_exits = self.build_expr(iterable, incoming);
1457                let branch = self.push_node(
1458                    node.span,
1459                    "for-in iteration".to_string(),
1460                    NodeSemantics::Branch,
1461                );
1462                self.connect_all(&iter_exits, branch);
1463
1464                let body_entry =
1465                    self.push_node(node.span, "for-in body".to_string(), NodeSemantics::Marker);
1466                self.connect(branch, body_entry);
1467                let body_exits = self.build_block(body, vec![body_entry]);
1468                self.connect_all(&body_exits, branch);
1469
1470                let after =
1471                    self.push_node(node.span, "for-in exit".to_string(), NodeSemantics::Marker);
1472                self.connect(branch, after);
1473                vec![after]
1474            }
1475            Node::WhileLoop { condition, body } => {
1476                let cond_exits = self.build_expr(condition, incoming);
1477                let branch = self.push_node(
1478                    node.span,
1479                    "while condition".to_string(),
1480                    NodeSemantics::Branch,
1481                );
1482                self.connect_all(&cond_exits, branch);
1483
1484                let body_entry =
1485                    self.push_node(node.span, "while body".to_string(), NodeSemantics::Marker);
1486                self.connect(branch, body_entry);
1487                let body_exits = self.build_block(body, vec![body_entry]);
1488                self.connect_all(&body_exits, branch);
1489
1490                let after =
1491                    self.push_node(node.span, "while exit".to_string(), NodeSemantics::Marker);
1492                self.connect(branch, after);
1493                vec![after]
1494            }
1495            Node::Retry { count, body } => {
1496                let count_exits = self.build_expr(count, incoming);
1497                let branch = self.push_node(
1498                    node.span,
1499                    "retry iteration".to_string(),
1500                    NodeSemantics::Branch,
1501                );
1502                self.connect_all(&count_exits, branch);
1503
1504                let body_entry =
1505                    self.push_node(node.span, "retry body".to_string(), NodeSemantics::Marker);
1506                self.connect(branch, body_entry);
1507                let body_exits = self.build_block(body, vec![body_entry]);
1508                self.connect_all(&body_exits, branch);
1509
1510                let after =
1511                    self.push_node(node.span, "retry exit".to_string(), NodeSemantics::Marker);
1512                self.connect(branch, after);
1513                vec![after]
1514            }
1515            Node::Parallel { expr, body, .. } => {
1516                let expr_exits = self.build_expr(expr, incoming);
1517                let branch = self.push_node(
1518                    node.span,
1519                    "parallel dispatch".to_string(),
1520                    NodeSemantics::Branch,
1521                );
1522                self.connect_all(&expr_exits, branch);
1523                let body_entry = self.push_node(
1524                    node.span,
1525                    "parallel body".to_string(),
1526                    NodeSemantics::Marker,
1527                );
1528                self.connect(branch, body_entry);
1529                let body_exits = self.build_block(body, vec![body_entry]);
1530                let after = self.push_node(
1531                    node.span,
1532                    "parallel join".to_string(),
1533                    NodeSemantics::Marker,
1534                );
1535                self.connect_all(&body_exits, after);
1536                self.connect(branch, after);
1537                vec![after]
1538            }
1539            Node::MatchExpr { value, arms } => {
1540                let value_exits = self.build_expr(value, incoming);
1541                let branch =
1542                    self.push_node(node.span, "match value".to_string(), NodeSemantics::Branch);
1543                self.connect_all(&value_exits, branch);
1544                let mut exits = Vec::new();
1545                for arm in arms {
1546                    let entry = self.push_node(
1547                        arm.pattern.span,
1548                        format!("match arm {}", pattern_label(&arm.pattern)),
1549                        NodeSemantics::Marker,
1550                    );
1551                    self.connect(branch, entry);
1552                    let arm_exits = if let Some(guard) = &arm.guard {
1553                        self.build_expr(guard, vec![entry])
1554                    } else {
1555                        vec![entry]
1556                    };
1557                    exits.extend(self.build_block(&arm.body, arm_exits));
1558                }
1559                exits
1560            }
1561            Node::TryCatch {
1562                has_catch: _,
1563                body,
1564                catch_body,
1565                finally_body,
1566                ..
1567            } => {
1568                let branch =
1569                    self.push_node(node.span, "try dispatch".to_string(), NodeSemantics::Branch);
1570                self.connect_all(&incoming, branch);
1571
1572                let try_entry =
1573                    self.push_node(node.span, "try body".to_string(), NodeSemantics::Marker);
1574                self.connect(branch, try_entry);
1575                let mut exits = self.build_block(body, vec![try_entry]);
1576
1577                let catch_entry =
1578                    self.push_node(node.span, "catch body".to_string(), NodeSemantics::Marker);
1579                self.connect(branch, catch_entry);
1580                exits.extend(self.build_block(catch_body, vec![catch_entry]));
1581
1582                if let Some(finally_body) = finally_body {
1583                    let finally_entry = self.push_node(
1584                        node.span,
1585                        "finally body".to_string(),
1586                        NodeSemantics::Marker,
1587                    );
1588                    self.connect_all(&exits, finally_entry);
1589                    return self.build_block(finally_body, vec![finally_entry]);
1590                }
1591
1592                exits
1593            }
1594            Node::TryExpr { body }
1595            | Node::SpawnExpr { body }
1596            | Node::DeferStmt { body }
1597            | Node::MutexBlock { body, .. }
1598            | Node::Block(body) => self.build_block(body, incoming),
1599            Node::DeadlineBlock { duration, body } => {
1600                let duration_exits = self.build_expr(duration, incoming);
1601                self.build_block(body, duration_exits)
1602            }
1603            Node::SelectExpr {
1604                cases,
1605                timeout,
1606                default_body,
1607            } => {
1608                let branch = self.push_node(node.span, "select".to_string(), NodeSemantics::Branch);
1609                self.connect_all(&incoming, branch);
1610                let mut exits = Vec::new();
1611                for case in cases {
1612                    let case_entry = self.push_node(
1613                        case.channel.span,
1614                        format!("select case {}", case.variable),
1615                        NodeSemantics::Marker,
1616                    );
1617                    self.connect(branch, case_entry);
1618                    let case_exits = self.build_expr(&case.channel, vec![case_entry]);
1619                    exits.extend(self.build_block(&case.body, case_exits));
1620                }
1621                if let Some((timeout_expr, timeout_body)) = timeout {
1622                    let timeout_entry = self.push_node(
1623                        timeout_expr.span,
1624                        "select timeout".to_string(),
1625                        NodeSemantics::Marker,
1626                    );
1627                    self.connect(branch, timeout_entry);
1628                    let timeout_exits = self.build_expr(timeout_expr, vec![timeout_entry]);
1629                    exits.extend(self.build_block(timeout_body, timeout_exits));
1630                }
1631                if let Some(default_body) = default_body {
1632                    let default_entry = self.push_node(
1633                        node.span,
1634                        "select default".to_string(),
1635                        NodeSemantics::Marker,
1636                    );
1637                    self.connect(branch, default_entry);
1638                    exits.extend(self.build_block(default_body, vec![default_entry]));
1639                }
1640                exits
1641            }
1642            Node::ReturnStmt { value } => {
1643                let exits = if let Some(value) = value.as_ref() {
1644                    self.build_expr(value, incoming)
1645                } else {
1646                    incoming
1647                };
1648                let ret = self.push_node(node.span, "return".to_string(), NodeSemantics::Return);
1649                self.connect_all(&exits, ret);
1650                Vec::new()
1651            }
1652            Node::ThrowStmt { value } => {
1653                let exits = self.build_expr(value, incoming);
1654                let throw = self.push_node(node.span, "throw".to_string(), NodeSemantics::Throw);
1655                self.connect_all(&exits, throw);
1656                Vec::new()
1657            }
1658            _ => self.build_expr(node, incoming),
1659        }
1660    }
1661
1662    fn build_expr(&mut self, node: &SNode, incoming: Vec<NodeId>) -> Vec<NodeId> {
1663        match &node.node {
1664            Node::FunctionCall { name, args, .. } => {
1665                self.build_function_call(node, name, args, incoming)
1666            }
1667            Node::HitlExpr { kind, args } => self.build_hitl_expr(node, *kind, args, incoming),
1668            Node::MethodCall {
1669                object,
1670                method,
1671                args,
1672            }
1673            | Node::OptionalMethodCall {
1674                object,
1675                method,
1676                args,
1677            } => self.build_method_call(node, object, method, args, incoming),
1678            Node::PropertyAccess { object, .. }
1679            | Node::OptionalPropertyAccess { object, .. }
1680            | Node::Spread(object)
1681            | Node::TryOperator { operand: object }
1682            | Node::TryStar { operand: object }
1683            | Node::UnaryOp {
1684                operand: object, ..
1685            } => self.build_expr(object, incoming),
1686            Node::SubscriptAccess { object, index }
1687            | Node::OptionalSubscriptAccess { object, index } => {
1688                let exits = self.build_expr(object, incoming);
1689                self.build_expr(index, exits)
1690            }
1691            Node::SliceAccess { object, start, end } => {
1692                let mut exits = self.build_expr(object, incoming);
1693                if let Some(start) = start {
1694                    exits = self.build_expr(start, exits);
1695                }
1696                if let Some(end) = end {
1697                    exits = self.build_expr(end, exits);
1698                }
1699                exits
1700            }
1701            Node::BinaryOp { left, right, .. } => {
1702                let exits = self.build_expr(left, incoming);
1703                self.build_expr(right, exits)
1704            }
1705            Node::Ternary {
1706                condition,
1707                true_expr,
1708                false_expr,
1709            } => {
1710                let cond_exits = self.build_expr(condition, incoming);
1711                let branch = self.push_node(
1712                    node.span,
1713                    "ternary condition".to_string(),
1714                    NodeSemantics::Branch,
1715                );
1716                self.connect_all(&cond_exits, branch);
1717                let true_entry =
1718                    self.push_node(node.span, "ternary true".to_string(), NodeSemantics::Marker);
1719                self.connect(branch, true_entry);
1720                let false_entry = self.push_node(
1721                    node.span,
1722                    "ternary false".to_string(),
1723                    NodeSemantics::Marker,
1724                );
1725                self.connect(branch, false_entry);
1726                let mut exits = self.build_expr(true_expr, vec![true_entry]);
1727                exits.extend(self.build_expr(false_expr, vec![false_entry]));
1728                exits
1729            }
1730            Node::ListLiteral(items) | Node::OrPattern(items) => {
1731                let mut exits = incoming;
1732                for item in items {
1733                    exits = self.build_expr(item, exits);
1734                }
1735                exits
1736            }
1737            Node::DictLiteral(entries)
1738            | Node::StructConstruct {
1739                fields: entries, ..
1740            } => {
1741                let mut exits = incoming;
1742                for entry in entries {
1743                    exits = self.build_expr(&entry.key, exits);
1744                    exits = self.build_expr(&entry.value, exits);
1745                }
1746                exits
1747            }
1748            Node::EnumConstruct { args, .. } => {
1749                let mut exits = incoming;
1750                for arg in args {
1751                    exits = self.build_expr(arg, exits);
1752                }
1753                exits
1754            }
1755            Node::Block(body) => self.build_block(body, incoming),
1756            Node::MatchExpr { .. } => self.build_stmt(node, incoming),
1757            Node::Closure { .. } => incoming,
1758            _ => incoming,
1759        }
1760    }
1761
1762    fn build_function_call(
1763        &mut self,
1764        node: &SNode,
1765        name: &str,
1766        args: &[SNode],
1767        incoming: Vec<NodeId>,
1768    ) -> Vec<NodeId> {
1769        if name == "dual_control" {
1770            let mut exits = incoming;
1771            for (index, arg) in args.iter().enumerate() {
1772                if index == 2 && matches!(arg.node, Node::Closure { .. }) {
1773                    continue;
1774                }
1775                exits = self.build_expr(arg, exits);
1776            }
1777            let enter = self.push_node(
1778                node.span,
1779                "dual_control approval gate".to_string(),
1780                NodeSemantics::ApprovalScopeEnter,
1781            );
1782            self.connect_all(&exits, enter);
1783            let closure_exits = match args.get(2) {
1784                Some(SNode {
1785                    node: Node::Closure { body, .. },
1786                    ..
1787                }) => self.build_block(body, vec![enter]),
1788                _ => vec![enter],
1789            };
1790            let exit = self.push_node(
1791                node.span,
1792                "end dual_control".to_string(),
1793                NodeSemantics::ApprovalScopeExit,
1794            );
1795            self.connect_all(&closure_exits, exit);
1796            return vec![exit];
1797        }
1798
1799        if let Some(scope) = scoped_policy_call(name) {
1800            return self.build_policy_scope_call(node, args, incoming, scope);
1801        }
1802
1803        let mut exits = incoming;
1804        for arg in args {
1805            exits = self.build_expr(arg, exits);
1806        }
1807        let call = classify_call(name, args);
1808        let call_id = self.push_node(
1809            node.span,
1810            format!("call {}", call.display_name),
1811            NodeSemantics::Call(call),
1812        );
1813        self.connect_all(&exits, call_id);
1814        vec![call_id]
1815    }
1816
1817    /// Lower a method call. The common case is a pass-through (walk the
1818    /// receiver + args). When the receiver is a `harness.<sub_handle>`
1819    /// access, we synthesize the ambient builtin name so capability
1820    /// classification (`harn graph --json`, routes, IR analysis) sees
1821    /// `harness.fs.read_text("x")` as having the same effect surface as
1822    /// the legacy `read_file("x")`. This is the single place the IR
1823    /// needs to know about the `Harness` sub-handle shape; everything
1824    /// downstream (`direct_capabilities`, `classify_call`) keeps using
1825    /// the canonical ambient name table.
1826    fn build_method_call(
1827        &mut self,
1828        node: &SNode,
1829        object: &SNode,
1830        method: &str,
1831        args: &[SNode],
1832        incoming: Vec<NodeId>,
1833    ) -> Vec<NodeId> {
1834        let mut exits = self.build_expr(object, incoming);
1835        for arg in args {
1836            exits = self.build_expr(arg, exits);
1837        }
1838        if let Some((sub_handle, ambient)) = harness_sub_handle_for(object, method) {
1839            let call = CallSemantics {
1840                name: ambient.to_string(),
1841                display_name: format!("harness.{sub_handle}.{method}"),
1842                classification: classify_call(ambient, args).classification,
1843                literal_args: literal_args(args),
1844            };
1845            let call_id = self.push_node(
1846                node.span,
1847                format!("call {}", call.display_name),
1848                NodeSemantics::Call(call),
1849            );
1850            self.connect_all(&exits, call_id);
1851            return vec![call_id];
1852        }
1853        exits
1854    }
1855
1856    fn build_policy_scope_call(
1857        &mut self,
1858        node: &SNode,
1859        args: &[SNode],
1860        incoming: Vec<NodeId>,
1861        scope: PolicyScopeKind,
1862    ) -> Vec<NodeId> {
1863        let closure_index = 1;
1864        let mut exits = incoming;
1865        for (index, arg) in args.iter().enumerate() {
1866            if index == closure_index && matches!(arg.node, Node::Closure { .. }) {
1867                continue;
1868            }
1869            exits = self.build_expr(arg, exits);
1870        }
1871        let enter = self.push_node(
1872            node.span,
1873            format!("enter {}", scope.label()),
1874            NodeSemantics::PolicyScopeEnter(scope),
1875        );
1876        self.connect_all(&exits, enter);
1877        let closure_exits = match args.get(closure_index) {
1878            Some(SNode {
1879                node: Node::Closure { body, .. },
1880                ..
1881            }) => self.build_block(body, vec![enter]),
1882            _ => vec![enter],
1883        };
1884        let exit = self.push_node(
1885            node.span,
1886            format!("exit {}", scope.label()),
1887            NodeSemantics::PolicyScopeExit(scope),
1888        );
1889        self.connect_all(&closure_exits, exit);
1890        vec![exit]
1891    }
1892
1893    fn build_hitl_expr(
1894        &mut self,
1895        node: &SNode,
1896        kind: HitlKind,
1897        args: &[HitlArg],
1898        incoming: Vec<NodeId>,
1899    ) -> Vec<NodeId> {
1900        match kind {
1901            HitlKind::RequestApproval => {
1902                let mut exits = incoming;
1903                for arg in args {
1904                    exits = self.build_expr(&arg.value, exits);
1905                }
1906                let call = CallSemantics {
1907                    name: kind.as_keyword().to_string(),
1908                    display_name: kind.as_keyword().to_string(),
1909                    classification: CallClassification::ApprovalGate,
1910                    literal_args: args
1911                        .iter()
1912                        .map(|arg| literal_value(&arg.value))
1913                        .collect::<Vec<_>>(),
1914                };
1915                let call_id = self.push_node(
1916                    node.span,
1917                    format!("call {}", kind.as_keyword()),
1918                    NodeSemantics::Call(call),
1919                );
1920                self.connect_all(&exits, call_id);
1921                vec![call_id]
1922            }
1923            HitlKind::DualControl => self.build_hitl_dual_control(node, args, incoming),
1924            HitlKind::AskUser | HitlKind::EscalateTo => {
1925                let mut exits = incoming;
1926                for arg in args {
1927                    exits = self.build_expr(&arg.value, exits);
1928                }
1929                exits
1930            }
1931        }
1932    }
1933
1934    fn build_hitl_dual_control(
1935        &mut self,
1936        node: &SNode,
1937        args: &[HitlArg],
1938        incoming: Vec<NodeId>,
1939    ) -> Vec<NodeId> {
1940        let closure_index = args
1941            .iter()
1942            .position(|arg| arg.name.as_deref() == Some("action"))
1943            .or(Some(2));
1944        let mut exits = incoming;
1945        for (index, arg) in args.iter().enumerate() {
1946            if Some(index) == closure_index && matches!(arg.value.node, Node::Closure { .. }) {
1947                continue;
1948            }
1949            exits = self.build_expr(&arg.value, exits);
1950        }
1951        let enter = self.push_node(
1952            node.span,
1953            "dual_control approval gate".to_string(),
1954            NodeSemantics::ApprovalScopeEnter,
1955        );
1956        self.connect_all(&exits, enter);
1957        let closure_exits = closure_index
1958            .and_then(|index| args.get(index))
1959            .and_then(|arg| match &arg.value {
1960                SNode {
1961                    node: Node::Closure { body, .. },
1962                    ..
1963                } => Some(self.build_block(body, vec![enter])),
1964                _ => None,
1965            })
1966            .unwrap_or_else(|| vec![enter]);
1967        let exit = self.push_node(
1968            node.span,
1969            "end dual_control".to_string(),
1970            NodeSemantics::ApprovalScopeExit,
1971        );
1972        self.connect_all(&closure_exits, exit);
1973        vec![exit]
1974    }
1975}
1976
1977fn scoped_policy_call(name: &str) -> Option<PolicyScopeKind> {
1978    match name {
1979        "with_execution_policy" => Some(PolicyScopeKind::Execution),
1980        "with_approval_policy" => Some(PolicyScopeKind::ToolApproval),
1981        "with_command_policy" => Some(PolicyScopeKind::Command),
1982        "with_autonomy_policy" => Some(PolicyScopeKind::Autonomy),
1983        "with_dynamic_permissions" => Some(PolicyScopeKind::DynamicPermissions),
1984        _ => None,
1985    }
1986}
1987
1988/// Collect the literal-arg vector for a synthesized harness CallSemantics
1989/// node. Mirrors the equivalent line in [`classify_call`] but avoids
1990/// re-running the classifier — the caller already supplies the ambient
1991/// name to dispatch through.
1992fn literal_args(args: &[SNode]) -> Vec<LiteralValue> {
1993    args.iter().map(literal_value).collect()
1994}
1995
1996/// If `object` is the `harness.<sub_handle>` chain and `method` maps
1997/// to an ambient builtin via the per-sub-handle dispatch table, return
1998/// the sub-handle name and the ambient builtin to attribute the call
1999/// to. Returns `None` for arbitrary method calls so the existing
2000/// pass-through walk continues to handle them.
2001fn harness_sub_handle_for(object: &SNode, method: &str) -> Option<(&'static str, &'static str)> {
2002    let (sub_handle, root) = match &object.node {
2003        Node::PropertyAccess { object, property }
2004        | Node::OptionalPropertyAccess { object, property } => (property.as_str(), object.as_ref()),
2005        _ => return None,
2006    };
2007    let Node::Identifier(receiver) = &root.node else {
2008        return None;
2009    };
2010    if receiver != "harness" && receiver != "_harness" {
2011        return None;
2012    }
2013    HARNESS_SUB_HANDLES
2014        .iter()
2015        .find(|slug| **slug == sub_handle)
2016        .and_then(|slug| {
2017            harn_parser::harness_methods::harness_sub_handle_ambient(slug, method)
2018                .map(|ambient| (*slug, ambient))
2019        })
2020}
2021
2022const HARNESS_SUB_HANDLES: &[&str] = &[
2023    "stdio", "term", "clock", "fs", "env", "random", "net", "process", "crypto", "system", "llm",
2024];
2025
2026fn classify_call(name: &str, args: &[SNode]) -> CallSemantics {
2027    let literal_args = args.iter().map(literal_value).collect::<Vec<_>>();
2028    let mut display_name = name.to_string();
2029    let classification = match name {
2030        "request_approval" => CallClassification::ApprovalGate,
2031        "llm_budget_remaining" | "agent_budget" | "llm_budget" => CallClassification::BudgetRead,
2032        "egress_policy" => CallClassification::PolicyGate(PolicyScopeKind::Egress),
2033        "command_policy_push" => CallClassification::PolicyPush(PolicyScopeKind::Command),
2034        "command_policy_pop" => CallClassification::PolicyPop(PolicyScopeKind::Command),
2035        "write_file" | "write_file_bytes" | "append_file" | "delete_file" | "mkdir" | "mkdtemp"
2036        | "apply_edit" | "move_file" => {
2037            let path = literal_args
2038                .first()
2039                .and_then(LiteralValue::as_str)
2040                .map(str::to_string);
2041            capability_classification(vec![CapabilityEffect::new(
2042                Capability::WorkspaceMutation,
2043                name,
2044                path,
2045            )])
2046        }
2047        "copy_file" => {
2048            let path = literal_args
2049                .get(1)
2050                .and_then(LiteralValue::as_str)
2051                .map(str::to_string);
2052            capability_classification(vec![CapabilityEffect::new(
2053                Capability::WorkspaceMutation,
2054                name,
2055                path,
2056            )])
2057        }
2058        "exec" | "exec_at" | "shell" | "shell_at" | "spawn_captured" => {
2059            capability_classification(vec![CapabilityEffect::new(
2060                Capability::CommandExecution,
2061                name,
2062                None,
2063            )])
2064        }
2065        "mcp_call" => {
2066            let tool_name = literal_args
2067                .get(1)
2068                .and_then(LiteralValue::as_str)
2069                .map(str::to_string);
2070            if let Some(tool_name) = tool_name {
2071                display_name = tool_name.clone();
2072                classify_tool_call(&tool_name, literal_args.get(2))
2073            } else {
2074                capability_classification(vec![CapabilityEffect::new(
2075                    Capability::ConnectorAccess,
2076                    name,
2077                    None,
2078                )])
2079            }
2080        }
2081        "host_tool_call" => {
2082            let tool_name = literal_args
2083                .first()
2084                .and_then(LiteralValue::as_str)
2085                .map(str::to_string);
2086            if let Some(tool_name) = tool_name {
2087                display_name = tool_name.clone();
2088                classify_tool_call(&tool_name, literal_args.get(1))
2089            } else {
2090                capability_classification(vec![CapabilityEffect::new(
2091                    Capability::ConnectorAccess,
2092                    name,
2093                    None,
2094                )])
2095            }
2096        }
2097        "host_call" => classify_host_call(literal_args.first()),
2098        _ if is_model_call(name) => capability_classification(vec![CapabilityEffect::new(
2099            Capability::ModelCall,
2100            name,
2101            None,
2102        )]),
2103        _ if is_worker_dispatch(name) => capability_classification(vec![CapabilityEffect::new(
2104            Capability::WorkerDispatch,
2105            name,
2106            None,
2107        )]),
2108        _ if is_network_call(name) => capability_classification(vec![CapabilityEffect::new(
2109            Capability::NetworkAccess,
2110            name,
2111            None,
2112        )]),
2113        _ if name.starts_with("mcp_") => capability_classification(vec![CapabilityEffect::new(
2114            Capability::ConnectorAccess,
2115            name,
2116            None,
2117        )]),
2118        _ => CallClassification::Other,
2119    };
2120
2121    CallSemantics {
2122        name: name.to_string(),
2123        display_name,
2124        classification,
2125        literal_args,
2126    }
2127}
2128
2129fn classify_tool_call(tool_name: &str, args: Option<&LiteralValue>) -> CallClassification {
2130    let normalized = tool_name.to_ascii_lowercase();
2131    let path = args.and_then(extract_path_from_tool_args);
2132    let mut effects = vec![CapabilityEffect::new(
2133        Capability::ConnectorAccess,
2134        tool_name,
2135        None,
2136    )];
2137    if matches!(
2138        normalized.as_str(),
2139        "write_file"
2140            | "append_file"
2141            | "copy_file"
2142            | "delete_file"
2143            | "mkdir"
2144            | "apply_edit"
2145            | "write"
2146            | "edit"
2147            | "delete"
2148            | "move"
2149            | "rename"
2150            | "patch"
2151    ) || normalized.contains("write")
2152        || normalized.contains("edit")
2153        || normalized.contains("delete")
2154        || normalized.contains("move")
2155        || normalized.contains("rename")
2156        || normalized.contains("patch")
2157    {
2158        effects.push(CapabilityEffect::new(
2159            Capability::WorkspaceMutation,
2160            tool_name,
2161            path,
2162        ));
2163    }
2164    if normalized.contains("exec")
2165        || normalized.contains("shell")
2166        || normalized.contains("run")
2167        || normalized.contains("push_pr")
2168        || normalized.contains("create_pr")
2169        || normalized.contains("deploy")
2170    {
2171        effects.push(CapabilityEffect::new(
2172            Capability::CommandExecution,
2173            tool_name,
2174            None,
2175        ));
2176    }
2177    capability_classification(effects)
2178}
2179
2180fn classify_host_call(name: Option<&LiteralValue>) -> CallClassification {
2181    let Some(operation) = name.and_then(LiteralValue::as_str) else {
2182        return capability_classification(vec![CapabilityEffect::new(
2183            Capability::ConnectorAccess,
2184            "host_call",
2185            None,
2186        )]);
2187    };
2188    if operation == "process.exec" || operation.starts_with("process.") {
2189        return capability_classification(vec![CapabilityEffect::new(
2190            Capability::CommandExecution,
2191            operation,
2192            None,
2193        )]);
2194    }
2195    if operation.starts_with("workspace.")
2196        && (operation.contains("write")
2197            || operation.contains("edit")
2198            || operation.contains("delete")
2199            || operation.contains("move")
2200            || operation.contains("patch"))
2201    {
2202        return capability_classification(vec![CapabilityEffect::new(
2203            Capability::WorkspaceMutation,
2204            operation,
2205            None,
2206        )]);
2207    }
2208    capability_classification(vec![CapabilityEffect::new(
2209        Capability::ConnectorAccess,
2210        operation,
2211        None,
2212    )])
2213}
2214
2215fn capability_classification(effects: Vec<CapabilityEffect>) -> CallClassification {
2216    if effects.is_empty() {
2217        CallClassification::Other
2218    } else {
2219        CallClassification::Capabilities(effects)
2220    }
2221}
2222
2223fn is_model_call(name: &str) -> bool {
2224    matches!(
2225        name,
2226        "llm_call"
2227            | "llm_call_safe"
2228            | "llm_stream_call"
2229            | "llm_call_structured"
2230            | "llm_call_structured_safe"
2231            | "llm_call_structured_result"
2232            | "llm_completion"
2233            | "agent_llm_turn"
2234            | "agent_turn"
2235            | "agent_loop"
2236    )
2237}
2238
2239fn is_worker_dispatch(name: &str) -> bool {
2240    matches!(
2241        name,
2242        "spawn_agent"
2243            | "send_input"
2244            | "resume_agent"
2245            | "wait_agent"
2246            | "close_agent"
2247            | "worker_trigger"
2248            | "__host_sub_agent_run"
2249            | "__host_worker_spawn"
2250            | "__host_worker_send_input"
2251            | "__host_worker_resume"
2252            | "__host_worker_trigger"
2253            | "__host_worker_wait"
2254            | "__host_worker_close"
2255    )
2256}
2257
2258fn is_network_call(name: &str) -> bool {
2259    matches!(
2260        name,
2261        "http_get"
2262            | "http_post"
2263            | "http_put"
2264            | "http_patch"
2265            | "http_delete"
2266            | "http_request"
2267            | "http_download"
2268            | "http_session"
2269            | "http_session_request"
2270            | "http_session_close"
2271            | "http_stream_open"
2272            | "http_stream_read"
2273            | "http_stream_close"
2274            | "sse_connect"
2275            | "sse_receive"
2276            | "sse_close"
2277            | "sse_server_response"
2278            | "sse_server_send"
2279            | "sse_server_heartbeat"
2280            | "sse_server_flush"
2281            | "sse_server_close"
2282            | "sse_server_cancel"
2283            | "websocket_accept"
2284            | "websocket_connect"
2285            | "websocket_send"
2286            | "websocket_receive"
2287            | "websocket_close"
2288            | "websocket_route"
2289            | "websocket_server"
2290            | "websocket_server_close"
2291            | "unix_socket_json_request"
2292            | "__net_unix_socket_json_request"
2293    )
2294}
2295
2296fn extract_path_from_tool_args(value: &LiteralValue) -> Option<String> {
2297    for key in ["path", "dst", "destination", "target"] {
2298        if let Some(path) = value.dict_field(key).and_then(LiteralValue::as_str) {
2299            return Some(path.to_string());
2300        }
2301    }
2302    None
2303}
2304
2305fn literal_value(node: &SNode) -> LiteralValue {
2306    match &node.node {
2307        Node::StringLiteral(value) | Node::RawStringLiteral(value) => {
2308            LiteralValue::String(value.clone())
2309        }
2310        Node::Identifier(value) => LiteralValue::Identifier(value.clone()),
2311        Node::IntLiteral(value) => LiteralValue::Number(value.to_string()),
2312        Node::FloatLiteral(value) => LiteralValue::Number(value.to_string()),
2313        Node::BoolLiteral(value) => LiteralValue::Bool(*value),
2314        Node::NilLiteral => LiteralValue::Nil,
2315        Node::DictLiteral(entries)
2316        | Node::StructConstruct {
2317            fields: entries, ..
2318        } => {
2319            let mut map = BTreeMap::new();
2320            for entry in entries {
2321                if let Some(key) = literal_key(&entry.key) {
2322                    map.insert(key, literal_value(&entry.value));
2323                }
2324            }
2325            LiteralValue::Dict(map)
2326        }
2327        Node::ListLiteral(items) => LiteralValue::List(items.iter().map(literal_value).collect()),
2328        _ => LiteralValue::Unknown,
2329    }
2330}
2331
2332fn literal_key(node: &SNode) -> Option<String> {
2333    match &node.node {
2334        Node::StringLiteral(value) | Node::RawStringLiteral(value) | Node::Identifier(value) => {
2335            Some(value.clone())
2336        }
2337        _ => None,
2338    }
2339}
2340
2341fn expr_summary(node: &SNode) -> ExprSummary {
2342    match &node.node {
2343        Node::Identifier(name) => ExprSummary::Reference(name.clone()),
2344        Node::PropertyAccess { .. } | Node::OptionalPropertyAccess { .. } => target_path(node)
2345            .map(ExprSummary::Reference)
2346            .unwrap_or(ExprSummary::Unknown),
2347        Node::FunctionCall { name, .. } => ExprSummary::Call(name.clone()),
2348        Node::BinaryOp { op, left, right } => ExprSummary::Binary {
2349            op: op.clone(),
2350            left: Box::new(expr_summary(left)),
2351            right: Box::new(expr_summary(right)),
2352        },
2353        Node::IntLiteral(_)
2354        | Node::FloatLiteral(_)
2355        | Node::StringLiteral(_)
2356        | Node::RawStringLiteral(_)
2357        | Node::BoolLiteral(_)
2358        | Node::NilLiteral => ExprSummary::Literal,
2359        _ => ExprSummary::Unknown,
2360    }
2361}
2362
2363fn assignment_is_non_increasing(assignment: &AssignmentSemantics, target: &str) -> bool {
2364    match assignment.op.as_deref() {
2365        Some("-") => true,
2366        Some("+") | Some("*") | Some("/") | Some("%") => false,
2367        Some(_) => false,
2368        None => match &assignment.value {
2369            ExprSummary::Reference(value) => value == target,
2370            ExprSummary::Call(name) => name == "llm_budget_remaining",
2371            ExprSummary::Binary { op, left, .. } if op == "-" => {
2372                matches!(left.as_ref(), ExprSummary::Reference(value) if value == target)
2373            }
2374            _ => false,
2375        },
2376    }
2377}
2378
2379fn path_to_node(ir: &HandlerIr, target: NodeId) -> Vec<PathStep> {
2380    let mut queue = VecDeque::new();
2381    let mut seen = HashSet::new();
2382    queue.push_back((ir.entry, vec![ir.entry]));
2383
2384    while let Some((node, path)) = queue.pop_front() {
2385        if node == target {
2386            return path
2387                .into_iter()
2388                .map(|id| {
2389                    let node = ir.node(id);
2390                    PathStep {
2391                        span: node.span,
2392                        label: node.label.clone(),
2393                    }
2394                })
2395                .collect();
2396        }
2397        if !seen.insert(node) {
2398            continue;
2399        }
2400        for succ in ir.successors(node) {
2401            let mut next_path = path.clone();
2402            next_path.push(succ);
2403            queue.push_back((succ, next_path));
2404        }
2405    }
2406
2407    Vec::new()
2408}
2409
2410fn target_path(node: &SNode) -> Option<String> {
2411    match &node.node {
2412        Node::Identifier(name) => Some(name.clone()),
2413        Node::PropertyAccess { object, property }
2414        | Node::OptionalPropertyAccess { object, property } => {
2415            let base = target_path(object)?;
2416            Some(format!("{base}.{property}"))
2417        }
2418        _ => None,
2419    }
2420}
2421
2422fn pattern_label(node: &SNode) -> String {
2423    match &node.node {
2424        Node::StringLiteral(value) | Node::RawStringLiteral(value) => format!("{value:?}"),
2425        Node::Identifier(value) => value.clone(),
2426        Node::IntLiteral(value) => value.to_string(),
2427        Node::BoolLiteral(value) => value.to_string(),
2428        Node::NilLiteral => "nil".to_string(),
2429        Node::OrPattern(_) => "or-pattern".to_string(),
2430        _ => "pattern".to_string(),
2431    }
2432}
2433
2434use harn_glob::match_path as glob_match;
2435
2436#[cfg(test)]
2437mod tests {
2438    use super::*;
2439
2440    fn parse_program(source: &str) -> Vec<SNode> {
2441        let mut lexer = harn_lexer::Lexer::new(source);
2442        let tokens = lexer.tokenize().expect("tokenize");
2443        let mut parser = harn_parser::Parser::new(tokens);
2444        parser.parse().expect("parse")
2445    }
2446
2447    fn analyze(source: &str) -> AnalysisReport {
2448        analyze_program(&parse_program(source))
2449    }
2450
2451    fn diagnostics_by_invariant<'a>(
2452        report: &'a AnalysisReport,
2453        invariant: &str,
2454    ) -> Vec<&'a InvariantDiagnostic> {
2455        report
2456            .diagnostics
2457            .iter()
2458            .filter(|diag| diag.invariant == invariant)
2459            .collect()
2460    }
2461
2462    fn handler_call_names(report: &AnalysisReport) -> Vec<String> {
2463        report
2464            .handlers
2465            .iter()
2466            .flat_map(|h| h.nodes.iter())
2467            .filter_map(|node| match &node.semantics {
2468                NodeSemantics::Call(call) => Some(call.name.clone()),
2469                _ => None,
2470            })
2471            .collect()
2472    }
2473
2474    #[test]
2475    fn harness_fs_method_call_is_attributed_to_read_file() {
2476        let report = analyze(
2477            r#"
2478fn main(harness: Harness) {
2479  let body = harness.fs.read_text("notes.txt")
2480  harness.fs.mkdtemp("harn-ir-")
2481  harness.stdio.println(body)
2482}
2483"#,
2484        );
2485
2486        let calls = handler_call_names(&report);
2487        assert!(
2488            calls.iter().any(|name| name == "read_file"),
2489            "expected harness.fs.read_text to lower to ambient read_file, got: {calls:?}"
2490        );
2491        assert!(
2492            calls.iter().any(|name| name == "mkdtemp"),
2493            "expected harness.fs.mkdtemp to lower to ambient mkdtemp, got: {calls:?}"
2494        );
2495        assert!(
2496            calls.iter().any(|name| name == "println"),
2497            "expected harness.stdio.println to lower to ambient println, got: {calls:?}"
2498        );
2499    }
2500
2501    #[test]
2502    fn harness_net_method_call_is_attributed_to_http_get() {
2503        let report = analyze(
2504            r#"
2505fn main(harness: Harness) {
2506  harness.net.get("https://api.example.com")
2507}
2508"#,
2509        );
2510
2511        let calls = handler_call_names(&report);
2512        assert!(
2513            calls.iter().any(|name| name == "http_get"),
2514            "expected harness.net.get to lower to ambient http_get, got: {calls:?}"
2515        );
2516    }
2517
2518    #[test]
2519    fn harness_term_method_calls_are_attributed_to_terminal_builtins() {
2520        let report = analyze(
2521            r#"
2522fn main(harness: Harness) {
2523  harness.term.width()
2524  harness.term.height()
2525  harness.term.read_password("password: ")
2526}
2527"#,
2528        );
2529
2530        let calls = handler_call_names(&report);
2531        assert!(
2532            calls.iter().any(|name| name == "term_width"),
2533            "expected harness.term.width to lower to ambient term_width, got: {calls:?}"
2534        );
2535        assert!(
2536            calls.iter().any(|name| name == "term_height"),
2537            "expected harness.term.height to lower to ambient term_height, got: {calls:?}"
2538        );
2539        assert!(
2540            calls.iter().any(|name| name == "read_password"),
2541            "expected harness.term.read_password to lower to read_password, got: {calls:?}"
2542        );
2543    }
2544
2545    #[test]
2546    fn harness_process_method_call_is_attributed_to_spawn_captured() {
2547        let report = analyze(
2548            r#"
2549fn main(harness: Harness) {
2550  harness.process.spawn_captured({cmd: "printf", args: ["hi"]})
2551}
2552"#,
2553        );
2554
2555        let calls = handler_call_names(&report);
2556        assert!(
2557            calls.iter().any(|name| name == "spawn_captured"),
2558            "expected harness.process.spawn_captured to lower to ambient spawn_captured, got: {calls:?}"
2559        );
2560    }
2561
2562    #[test]
2563    fn harness_crypto_method_call_is_attributed_to_sha256_hex() {
2564        let report = analyze(
2565            r#"
2566fn main(harness: Harness) {
2567  harness.crypto.sha256("hello")
2568}
2569"#,
2570        );
2571
2572        let calls = handler_call_names(&report);
2573        assert!(
2574            calls.iter().any(|name| name == "sha256_hex"),
2575            "expected harness.crypto.sha256 to lower to ambient sha256_hex, got: {calls:?}"
2576        );
2577    }
2578
2579    #[test]
2580    fn harness_llm_method_calls_are_attributed_to_llm_catalog_builtins() {
2581        let report = analyze(
2582            r"
2583fn main(harness: Harness) {
2584  harness.llm.catalog()
2585  harness.llm.providers()
2586}
2587",
2588        );
2589
2590        let calls = handler_call_names(&report);
2591        assert!(
2592            calls.iter().any(|name| name == "llm_catalog"),
2593            "expected harness.llm.catalog to lower to llm_catalog, got: {calls:?}"
2594        );
2595        assert!(
2596            calls.iter().any(|name| name == "llm_provider_status"),
2597            "expected harness.llm.providers to lower to llm_provider_status, got: {calls:?}"
2598        );
2599    }
2600
2601    #[test]
2602    fn fs_writes_within_glob_passes() {
2603        let report = analyze(
2604            r#"
2605@invariant("fs.writes", "src/**")
2606fn handler() {
2607  write_file("src/main.rs", "ok")
2608}
2609"#,
2610        );
2611
2612        assert!(
2613            diagnostics_by_invariant(&report, "fs.writes").is_empty(),
2614            "unexpected diagnostics: {:?}",
2615            report.diagnostics
2616        );
2617    }
2618
2619    #[test]
2620    fn fs_writes_outside_glob_fails() {
2621        let report = analyze(
2622            r#"
2623@invariant("fs.writes", "src/**")
2624fn handler() {
2625  write_file("/tmp/main.rs", "nope")
2626}
2627"#,
2628        );
2629
2630        let diags = diagnostics_by_invariant(&report, "fs.writes");
2631        assert_eq!(diags.len(), 1);
2632        assert!(diags[0].message.contains("outside the allowed glob"));
2633        assert!(diags[0]
2634            .path
2635            .iter()
2636            .any(|step| step.label.contains("write_file")));
2637    }
2638
2639    #[test]
2640    fn approval_requires_gate_on_all_paths() {
2641        let report = analyze(
2642            r#"
2643@invariant("approval.reachability")
2644fn handler() {
2645  if true {
2646    request_approval("ship it")
2647  }
2648  write_file("src/main.rs", "unsafe")
2649}
2650"#,
2651        );
2652
2653        let diags = diagnostics_by_invariant(&report, "approval.reachability");
2654        assert_eq!(diags.len(), 1);
2655        assert!(diags[0].message.contains("before any approval gate"));
2656    }
2657
2658    #[test]
2659    fn approval_inside_dual_control_closure_is_accepted() {
2660        let report = analyze(
2661            r#"
2662@invariant("approval.reachability")
2663fn handler() {
2664  dual_control(2, 3, { ->
2665    write_file("src/main.rs", "safe")
2666  }, ["alice", "bob", "carol"])
2667}
2668"#,
2669        );
2670
2671        assert!(
2672            diagnostics_by_invariant(&report, "approval.reachability").is_empty(),
2673            "unexpected diagnostics: {:?}",
2674            report.diagnostics
2675        );
2676    }
2677
2678    #[test]
2679    fn budget_remaining_rejects_addition() {
2680        let report = analyze(
2681            r#"
2682@invariant("budget.remaining", target: "remaining")
2683fn handler() {
2684  let remaining = llm_budget_remaining()
2685  remaining = remaining + 1
2686}
2687"#,
2688        );
2689
2690        let diags = diagnostics_by_invariant(&report, "budget.remaining");
2691        assert_eq!(diags.len(), 1);
2692        assert!(diags[0].message.contains("may increase"));
2693    }
2694
2695    #[test]
2696    fn budget_remaining_accepts_subtraction() {
2697        let report = analyze(
2698            r#"
2699@invariant("budget.remaining", target: "remaining")
2700fn handler(cost) {
2701  let remaining = llm_budget_remaining()
2702  remaining -= cost
2703}
2704"#,
2705        );
2706
2707        assert!(
2708            diagnostics_by_invariant(&report, "budget.remaining").is_empty(),
2709            "unexpected diagnostics: {:?}",
2710            report.diagnostics
2711        );
2712    }
2713
2714    #[test]
2715    fn capability_policy_rejects_undeclared_connector_access() {
2716        let report = analyze(
2717            r#"
2718@invariant("capability.policy", allow: "fs.write")
2719fn handler(client) {
2720  mcp_call(client, "github.search", {})
2721}
2722"#,
2723        );
2724
2725        let diags = diagnostics_by_invariant(&report, "capability.policy");
2726        assert_eq!(diags.len(), 1);
2727        assert!(diags[0].message.contains("mcp.connector"));
2728        assert!(diags[0].message.contains("not declared"));
2729        assert_eq!(diags[0].handler, "handler");
2730        assert!(diags[0]
2731            .path
2732            .iter()
2733            .any(|step| step.label.contains("github.search")));
2734    }
2735
2736    #[test]
2737    fn capability_policy_rejects_workspace_mutation_outside_allowed_glob() {
2738        let report = analyze(
2739            r#"
2740@invariant("capability.policy", allow: "fs.write", workspace: "src/**")
2741fn handler() {
2742  write_file("/tmp/out.txt", "unsafe")
2743}
2744"#,
2745        );
2746
2747        let diags = diagnostics_by_invariant(&report, "capability.policy");
2748        assert_eq!(diags.len(), 1);
2749        assert!(diags[0]
2750            .message
2751            .contains("outside the allowed workspace glob"));
2752    }
2753
2754    #[test]
2755    fn capability_policy_accepts_approved_workspace_mutation_and_budgeted_llm() {
2756        let report = analyze(
2757            r#"
2758@invariant("capability.policy",
2759  allow: "fs.write,llm.model",
2760  workspace: "src/**",
2761  require_approval: "fs.write",
2762  require_budget: "llm.model")
2763fn handler() {
2764  request_approval("edit", {capabilities_requested: ["fs.write"]})
2765  write_file("src/main.rs", "safe")
2766  llm_call("summarize", nil, {budget: {max_output_tokens: 64}})
2767}
2768"#,
2769        );
2770
2771        assert!(
2772            diagnostics_by_invariant(&report, "capability.policy").is_empty(),
2773            "unexpected diagnostics: {:?}",
2774            report.diagnostics
2775        );
2776    }
2777
2778    #[test]
2779    fn capability_policy_requires_command_policy_for_exec() {
2780        let report = analyze(
2781            r#"
2782@invariant("capability.policy",
2783  allow: "process.exec",
2784  require_command_policy: "process.exec")
2785fn handler() {
2786  exec("rm -rf /tmp/harn")
2787}
2788"#,
2789        );
2790
2791        let diags = diagnostics_by_invariant(&report, "capability.policy");
2792        assert_eq!(diags.len(), 1);
2793        assert!(diags[0].message.contains("process.exec"));
2794        assert!(diags[0].message.contains("command policy"));
2795
2796        let report = analyze(
2797            r#"
2798@invariant("capability.policy",
2799  allow: "process.exec",
2800  require_command_policy: "process.exec")
2801fn handler() {
2802  with_command_policy({deny: ["rm"]}, { ->
2803    exec("echo ok")
2804  })
2805}
2806"#,
2807        );
2808
2809        assert!(
2810            diagnostics_by_invariant(&report, "capability.policy").is_empty(),
2811            "unexpected diagnostics: {:?}",
2812            report.diagnostics
2813        );
2814    }
2815
2816    #[test]
2817    fn capability_policy_tracks_command_policy_push_and_pop() {
2818        let report = analyze(
2819            r#"
2820@invariant("capability.policy",
2821  allow: "process.exec",
2822  require_command_policy: "process.exec")
2823fn handler() {
2824  command_policy_push({deny: ["rm"]})
2825  exec("echo ok")
2826  command_policy_pop()
2827}
2828"#,
2829        );
2830
2831        assert!(
2832            diagnostics_by_invariant(&report, "capability.policy").is_empty(),
2833            "unexpected diagnostics: {:?}",
2834            report.diagnostics
2835        );
2836
2837        let report = analyze(
2838            r#"
2839@invariant("capability.policy",
2840  allow: "process.exec",
2841  require_command_policy: "process.exec")
2842fn handler() {
2843  command_policy_push({deny: ["rm"]})
2844  command_policy_pop()
2845  exec("echo unsafe")
2846}
2847"#,
2848        );
2849
2850        let diags = diagnostics_by_invariant(&report, "capability.policy");
2851        assert_eq!(diags.len(), 1);
2852        assert!(diags[0].message.contains("command policy"));
2853    }
2854
2855    #[test]
2856    fn capability_policy_requires_egress_policy_for_network_and_connector_access() {
2857        let report = analyze(
2858            r#"
2859@invariant("capability.policy",
2860  allow: "network.access,mcp.connector",
2861  require_egress_policy: "network.access,mcp.connector")
2862fn handler(client) {
2863  http_request("https://example.com")
2864  mcp_call(client, "github.search", {})
2865}
2866"#,
2867        );
2868
2869        let diags = diagnostics_by_invariant(&report, "capability.policy");
2870        assert_eq!(diags.len(), 2);
2871        assert!(diags
2872            .iter()
2873            .any(|diag| diag.message.contains("network.access")));
2874        assert!(diags
2875            .iter()
2876            .any(|diag| diag.message.contains("mcp.connector")));
2877
2878        let report = analyze(
2879            r#"
2880@invariant("capability.policy",
2881  allow: "network.access,mcp.connector",
2882  require_egress_policy: "network.access,mcp.connector")
2883fn handler(client) {
2884  egress_policy({default: "deny", allow: ["example.com"]})
2885  http_request("https://example.com")
2886  mcp_call(client, "github.search", {})
2887}
2888"#,
2889        );
2890
2891        assert!(
2892            diagnostics_by_invariant(&report, "capability.policy").is_empty(),
2893            "unexpected diagnostics: {:?}",
2894            report.diagnostics
2895        );
2896    }
2897
2898    #[test]
2899    fn capability_policy_treats_unix_socket_json_request_as_network_access() {
2900        let report = analyze(
2901            r#"
2902@invariant("capability.policy",
2903  allow: "network.access",
2904  require_egress_policy: "network.access")
2905fn handler() {
2906  unix_socket_json_request("/tmp/harn.sock", {})
2907}
2908"#,
2909        );
2910
2911        let diags = diagnostics_by_invariant(&report, "capability.policy");
2912        assert_eq!(diags.len(), 1);
2913        assert!(diags[0].message.contains("network.access"));
2914    }
2915
2916    #[test]
2917    fn capability_policy_requires_autonomy_policy_for_worker_dispatch() {
2918        let report = analyze(
2919            r#"
2920@invariant("capability.policy",
2921  allow: "worker.dispatch",
2922  require_autonomy: "worker.dispatch")
2923fn handler() {
2924  spawn_agent({task: "summarize"})
2925}
2926"#,
2927        );
2928
2929        let diags = diagnostics_by_invariant(&report, "capability.policy");
2930        assert_eq!(diags.len(), 1);
2931        assert!(diags[0].message.contains("worker.dispatch"));
2932        assert!(diags[0].message.contains("autonomy policy"));
2933
2934        let report = analyze(
2935            r#"
2936@invariant("capability.policy",
2937  allow: "worker.dispatch",
2938  require_autonomy: "worker.dispatch")
2939fn handler() {
2940  with_autonomy_policy({autonomy_tier: "act_with_approval"}, { ->
2941    spawn_agent({task: "summarize"})
2942  })
2943}
2944"#,
2945        );
2946
2947        assert!(
2948            diagnostics_by_invariant(&report, "capability.policy").is_empty(),
2949            "unexpected diagnostics: {:?}",
2950            report.diagnostics
2951        );
2952    }
2953
2954    #[test]
2955    fn explain_returns_violation_path() {
2956        let diags = explain_handler_invariant(
2957            &parse_program(
2958                r#"
2959@invariant("approval.reachability")
2960fn handler() {
2961  write_file("src/main.rs", "unsafe")
2962}
2963"#,
2964            ),
2965            "handler",
2966            "approval.reachability",
2967        )
2968        .expect("explain succeeds");
2969
2970        assert_eq!(diags.len(), 1);
2971        assert!(diags[0].path.len() >= 2);
2972    }
2973
2974    #[test]
2975    fn glob_match_supports_single_and_double_star() {
2976        assert!(glob_match("src/*.rs", "src/main.rs"));
2977        assert!(!glob_match("src/*.rs", "src/nested/main.rs"));
2978        assert!(glob_match("src/**/*.rs", "src/nested/main.rs"));
2979        // `**/` also matches zero directories (git/globset convention).
2980        assert!(glob_match("src/**/*.rs", "src/main.rs"));
2981    }
2982}