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 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
1988fn literal_args(args: &[SNode]) -> Vec<LiteralValue> {
1993 args.iter().map(literal_value).collect()
1994}
1995
1996fn 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 assert!(glob_match("src/**/*.rs", "src/main.rs"));
2981 }
2982}