aspect-std 0.1.2

Standard aspects library for aspect-rs AOP framework
Documentation
//! Integration test: the four-aspect composition at the tool-dispatch
//! join point identified in §3.2 of the ICSE 2027 revision plan.
//!
//! Firing order:
//!   ToolScopeSandbox (outermost, denies out-of-scope tools)
//! -> RateLimitAspect (cap requests/sec per session)
//! -> ActionAuditTrailAspect (record every invocation)
//! -> LoggingAspect (innermost, structured logging)
//! -> target (the dispatcher itself)

use aspect_runtime::{
    compare_orderings, detect_short_circuits, ChainSlot, Composition, Interaction, Phase,
};
use aspect_std::{
    ActionAuditTrailAspect, AuditContext, LoggingAspect, RateLimitAspect, ToolCall,
    ToolScopeSandboxAspect,
};
use std::time::Duration;

fn build_chain(
    sandbox: ToolScopeSandboxAspect,
    limiter: RateLimitAspect,
    audit: ActionAuditTrailAspect,
    logger: LoggingAspect,
) -> Composition {
    Composition::new(vec![
        ChainSlot::new("ToolScopeSandbox", sandbox),
        ChainSlot::new("RateLimiter", limiter),
        ChainSlot::new("ActionAuditTrail", audit),
        ChainSlot::new("StructuredLogger", logger),
    ])
}

#[test]
fn allowed_tool_passes_through_all_four_aspects() {
    let sandbox = ToolScopeSandboxAspect::new();
    sandbox.allow("s1", ["read_file"]);
    let limiter = RateLimitAspect::new(10, Duration::from_secs(1));
    let audit = ActionAuditTrailAspect::new();
    let logger = LoggingAspect::new();

    let sandbox_key = sandbox.bind(ToolCall {
        session: "s1".into(),
        tool: "read_file".into(),
    });
    let audit_key = audit.bind(AuditContext {
        session: "s1".into(),
        tool: "read_file".into(),
    });

    let chain = build_chain(sandbox.clone(), limiter, audit.clone(), logger);
    let result = chain.run("execute_tool_call", || Ok::<i32, _>(42));
    sandbox.release(sandbox_key);
    audit.release(audit_key);

    assert!(result.is_ok());

    let trace = chain.trace().snapshot();
    let entries: Vec<_> = trace.iter().map(|t| (t.aspect.as_str(), t.phase)).collect();
    assert_eq!(
        entries,
        vec![
            ("ToolScopeSandbox", Phase::AroundEnter),
            ("RateLimiter", Phase::AroundEnter),
            ("ActionAuditTrail", Phase::AroundEnter),
            ("StructuredLogger", Phase::AroundEnter),
            ("StructuredLogger", Phase::AroundExit),
            ("ActionAuditTrail", Phase::AroundExit),
            ("RateLimiter", Phase::AroundExit),
            ("ToolScopeSandbox", Phase::AroundExit),
        ]
    );

    let trail = audit.snapshot();
    assert_eq!(trail.len(), 1);
    assert!(trail[0].ok);
}

#[test]
fn sandbox_short_circuits_inner_three_aspects() {
    let sandbox = ToolScopeSandboxAspect::new();
    sandbox.allow("s1", ["read_file"]);
    let limiter = RateLimitAspect::new(10, Duration::from_secs(1));
    let audit = ActionAuditTrailAspect::new();
    let logger = LoggingAspect::new();

    let sandbox_key = sandbox.bind(ToolCall {
        session: "s1".into(),
        tool: "delete_file".into(),
    });

    let chain = build_chain(sandbox.clone(), limiter, audit.clone(), logger);
    let result = chain.run("execute_tool_call", || Ok::<i32, _>(0));
    sandbox.release(sandbox_key);

    assert!(result.is_err());

    let declared = vec![
        "ToolScopeSandbox".to_string(),
        "RateLimiter".to_string(),
        "ActionAuditTrail".to_string(),
        "StructuredLogger".to_string(),
    ];
    let interactions = detect_short_circuits(&declared, &chain.trace().snapshot());
    let short_circuit = interactions
        .iter()
        .find(|i| matches!(i, Interaction::ShortCircuit { .. }))
        .expect("expected a short-circuit interaction");
    if let Interaction::ShortCircuit { culprit, skipped } = short_circuit {
        assert_eq!(culprit, "ToolScopeSandbox");
        assert_eq!(
            skipped,
            &vec![
                "RateLimiter".to_string(),
                "ActionAuditTrail".to_string(),
                "StructuredLogger".to_string()
            ]
        );
    }

    // The audit trail must not contain a record for the denied call,
    // because the sandbox short-circuited before it ran.
    assert!(audit.is_empty());
}

#[test]
fn moving_audit_outside_sandbox_changes_trace() {
    // Compare two orderings of the same aspect set. In ordering A the
    // sandbox is outermost (production default). In ordering B the
    // audit trail is outermost — so a denied call still produces an
    // audit record. Both deny, but the traces diverge.

    let make_aspects = || {
        let sandbox = ToolScopeSandboxAspect::new();
        sandbox.allow("s1", ["read_file"]);
        let limiter = RateLimitAspect::new(10, Duration::from_secs(1));
        let audit = ActionAuditTrailAspect::new();
        let logger = LoggingAspect::new();
        (sandbox, limiter, audit, logger)
    };

    // Ordering A: Sandbox outermost.
    let (sa, la, aa, lga) = make_aspects();
    let sa_key = sa.bind(ToolCall {
        session: "s1".into(),
        tool: "delete_file".into(),
    });
    let chain_a = Composition::new(vec![
        ChainSlot::new("ToolScopeSandbox", sa.clone()),
        ChainSlot::new("RateLimiter", la),
        ChainSlot::new("ActionAuditTrail", aa.clone()),
        ChainSlot::new("StructuredLogger", lga),
    ]);
    let r_a = chain_a.run("execute_tool_call", || Ok::<i32, _>(0));
    sa.release(sa_key);

    // Ordering B: Audit outermost.
    let (sb, lb, ab, lgb) = make_aspects();
    let sb_key = sb.bind(ToolCall {
        session: "s1".into(),
        tool: "delete_file".into(),
    });
    let ab_key = ab.bind(AuditContext {
        session: "s1".into(),
        tool: "delete_file".into(),
    });
    let chain_b = Composition::new(vec![
        ChainSlot::new("ActionAuditTrail", ab.clone()),
        ChainSlot::new("ToolScopeSandbox", sb.clone()),
        ChainSlot::new("RateLimiter", lb),
        ChainSlot::new("StructuredLogger", lgb),
    ]);
    let r_b = chain_b.run("execute_tool_call", || Ok::<i32, _>(0));
    sb.release(sb_key);
    ab.release(ab_key);

    assert!(r_a.is_err() && r_b.is_err());
    let interactions = compare_orderings(
        &chain_a.trace().snapshot(),
        r_a.is_ok(),
        &chain_b.trace().snapshot(),
        r_b.is_ok(),
    );
    assert!(interactions
        .iter()
        .any(|i| matches!(i, Interaction::OrderSensitive { .. })));

    // Concretely: ordering A produced no audit record, ordering B did.
    assert!(aa.is_empty());
    assert_eq!(ab.len(), 1);
    assert!(!ab.snapshot()[0].ok);
}